Merge branch 'master' into mobile-sidebar
Signed-off-by: Philipp Hugenroth <philipph@spotify.com>
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/create-app': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Addressed some peer dependency warnings
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Can specify allowedOwners to the RepoUrlPicker picker in a template definition
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Bump esbuild to ^0.14.1
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes': minor
|
||||
'@backstage/plugin-kubernetes-backend': minor
|
||||
'@backstage/plugin-kubernetes-common': minor
|
||||
---
|
||||
|
||||
Add pod metrics lookup and display in pod table.
|
||||
|
||||
## Backwards incompatible changes
|
||||
|
||||
If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed,
|
||||
you will need to set the `skipMetricsLookup` config flag to `false`.
|
||||
|
||||
See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Reject catalog entities that have duplicate fields that vary only in casing.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Fix bug with setting owner in RepoUrlPicker causing validation failure
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Use ellipsis style for overflowed text in sidebar menu
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Add cli option to minify the generated code of a plugin or backend package
|
||||
|
||||
```
|
||||
backstage-cli plugin:build --minify
|
||||
backstage-cli backend:build --minify
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
---
|
||||
|
||||
Update AsyncAPI component to 1.0.0-x releases
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Export SearchApi interface from plugin
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-analytics-module-ga': patch
|
||||
---
|
||||
|
||||
Support self hosted analytics.js script via `scriptSrc` config option
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Add knexConfig config section
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
'@backstage/techdocs-common': minor
|
||||
'@backstage/plugin-techdocs-backend': minor
|
||||
---
|
||||
|
||||
Added the ability for the TechDocs Backend to (optionally) leverage a cache
|
||||
store to improve performance when reading files from a cloud storage provider.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
TechDocs Backend may now (optionally) leverage a cache store to improve
|
||||
performance when reading content from a cloud storage provider.
|
||||
|
||||
To apply this change to an existing app, pass the cache manager from the plugin
|
||||
environment to the `createRouter` function in your backend:
|
||||
|
||||
```diff
|
||||
// packages/backend/src/plugins/techdocs.ts
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
reader,
|
||||
+ cache,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
|
||||
// ...
|
||||
|
||||
return await createRouter({
|
||||
preparers,
|
||||
generators,
|
||||
publisher,
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
+ cache,
|
||||
});
|
||||
```
|
||||
|
||||
If your `PluginEnvironment` does not include a cache manager, be sure you've
|
||||
applied [the cache management change][cm-change] to your backend as well.
|
||||
|
||||
[Additional configuration][td-rec-arch] is required if you wish to enable
|
||||
caching in TechDocs.
|
||||
|
||||
[cm-change]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#patch-changes-6
|
||||
[td-rec-arch]: https://backstage.io/docs/features/techdocs/architecture#recommended-deployment
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Bump @spotify/prettier-config
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-xcmetrics': patch
|
||||
---
|
||||
|
||||
Handle a case where XCode data from backend (before 0.0.8) could be missing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-bazaar-backend': patch
|
||||
---
|
||||
|
||||
Handle migration error when old data is present in the database
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Removed the `scaffolder.github.visibility` configuration that is no longer used from the default app template.
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
|
||||
| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
|
||||
| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
|
||||
| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
|
||||
| [SDA SE](https://sda.se) | [@dschwank](https://github.com/dschwank), [@iammnils](https://github.com/iammnils) | Central place for developing and sharing services in our insurance ecosystem. |
|
||||
| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
|
||||
| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
|
||||
| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
|
||||
|
||||
@@ -11,13 +11,13 @@ would be good to also have some files in there that can be templated in.
|
||||
A simple `template.yaml` definition might look something like this:
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1beta2
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
# some metadata about the template itself
|
||||
metadata:
|
||||
name: v1beta2-demo
|
||||
name: v1beta3-demo
|
||||
title: Test Action template
|
||||
description: scaffolder v1beta2 template demo
|
||||
description: scaffolder v1beta3 template demo
|
||||
spec:
|
||||
owner: backstage/techdocs-core
|
||||
type: service
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
name: '{{ parameters.name }}'
|
||||
name: ${{ parameters.name }}
|
||||
|
||||
- id: fetch-docs
|
||||
name: Fetch Docs
|
||||
@@ -69,14 +69,14 @@ spec:
|
||||
action: publish:github
|
||||
input:
|
||||
allowedHosts: ['github.com']
|
||||
description: 'This is {{ parameters.name }}'
|
||||
repoUrl: '{{ parameters.repoUrl }}'
|
||||
description: This is ${{ parameters.name }}
|
||||
repoUrl: ${{ parameters.repoUrl }}
|
||||
|
||||
- id: register
|
||||
name: Register
|
||||
action: catalog:register
|
||||
input:
|
||||
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
|
||||
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
|
||||
catalogInfoPath: '/catalog-info.yaml'
|
||||
```
|
||||
|
||||
|
||||
@@ -6,21 +6,21 @@ description: Details around creating your own custom Software Templates
|
||||
|
||||
Templates are stored in the **Software Catalog** under a kind `Template`. You
|
||||
can create your own templates with a small `yaml` definition which describes the
|
||||
template and it's metadata, along with some input variables that your template
|
||||
template and its metadata, along with some input variables that your template
|
||||
will need, and then a list of actions which are then executed by the scaffolding
|
||||
service.
|
||||
|
||||
Let's take a look at a simple example:
|
||||
|
||||
```yaml
|
||||
# Notice the v1beta2 version
|
||||
apiVersion: backstage.io/v1beta2
|
||||
# Notice the v1beta3 version
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
# some metadata about the template itself
|
||||
metadata:
|
||||
name: v1beta2-demo
|
||||
name: v1beta3-demo
|
||||
title: Test Action template
|
||||
description: scaffolder v1beta2 template demo
|
||||
description: scaffolder v1beta3 template demo
|
||||
spec:
|
||||
owner: backstage/techdocs-core
|
||||
type: service
|
||||
@@ -66,8 +66,8 @@ spec:
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
name: '{{ parameters.name }}'
|
||||
owner: '{{ parameters.owner }}'
|
||||
name: ${{ parameters.name }}
|
||||
owner: ${{ parameters.owner }}
|
||||
|
||||
- id: fetch-docs
|
||||
name: Fetch Docs
|
||||
@@ -81,20 +81,20 @@ spec:
|
||||
action: publish:github
|
||||
input:
|
||||
allowedHosts: ['github.com']
|
||||
description: 'This is {{ parameters.name }}'
|
||||
repoUrl: '{{ parameters.repoUrl }}'
|
||||
description: This is ${{ parameters.name }}
|
||||
repoUrl: ${{ parameters.repoUrl }}
|
||||
|
||||
- id: register
|
||||
name: Register
|
||||
action: catalog:register
|
||||
input:
|
||||
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
|
||||
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
|
||||
catalogInfoPath: '/catalog-info.yaml'
|
||||
|
||||
# some outputs which are saved along with the job for use in the frontend
|
||||
output:
|
||||
remoteUrl: '{{ steps.publish.output.remoteUrl }}'
|
||||
entityRef: '{{ steps.register.output.entityRef }}'
|
||||
remoteUrl: ${{ steps.publish.output.remoteUrl }}
|
||||
entityRef: ${{ steps.register.output.entityRef }}
|
||||
```
|
||||
|
||||
Let's dive in and pick apart what each of these sections do and what they are.
|
||||
@@ -183,12 +183,12 @@ this:
|
||||
It would look something like the following in a template:
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1beta2
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
metadata:
|
||||
name: v1beta2-demo
|
||||
name: v1beta3-demo
|
||||
title: Test Action template
|
||||
description: scaffolder v1beta2 template demo
|
||||
description: scaffolder v1beta3 template demo
|
||||
spec:
|
||||
owner: backstage/techdocs-core
|
||||
type: service
|
||||
@@ -315,12 +315,12 @@ template. These follow the same standard format:
|
||||
```yaml
|
||||
- id: fetch-base # A unique id for the step
|
||||
name: Fetch Base # A title displayed in the frontend
|
||||
if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy
|
||||
if: ${{ parameters.name }} # Optional condition, skip the step if not truthy
|
||||
action: fetch:template # An action to call
|
||||
input: # Input that is passed as arguments to the action handler
|
||||
url: ./template
|
||||
values:
|
||||
name: '{{ parameters.name }}'
|
||||
name: ${{ parameters.name }}
|
||||
```
|
||||
|
||||
By default we ship some [built in actions](./builtin-actions.md) that you can
|
||||
@@ -338,22 +338,19 @@ The main two that are used are the following:
|
||||
|
||||
```yaml
|
||||
output:
|
||||
remoteUrl: '{{ steps.publish.output.remoteUrl }}' # link to the remote repository
|
||||
entityRef: '{{ steps.register.output.entityRef }}' # link to the entity that has been ingested to the catalog
|
||||
remoteUrl: ${{ steps.publish.output.remoteUrl }} # link to the remote repository
|
||||
entityRef: ${{ steps.register.output.entityRef }} # link to the entity that has been ingested to the catalog
|
||||
```
|
||||
|
||||
### The templating syntax
|
||||
|
||||
You might have noticed variables wrapped in `{{ }}` in the examples. These are
|
||||
`handlebars` template strings for linking and gluing the different parts of the
|
||||
template together. All the form inputs from the `parameters` section will be
|
||||
available by using this template syntax (for example,
|
||||
`{{ parameters.firstName }}` inserts the value of `firstName` from the
|
||||
parameters). This is great for passing the values from the form into different
|
||||
steps and reusing these input variables. To pass arrays or objects use the
|
||||
`json` custom [helper](https://handlebarsjs.com/guide/expressions.html#helpers).
|
||||
For example, `{{ json parameters.nicknames }}` will insert the result of calling
|
||||
`JSON.stringify` on the value of the `nicknames` parameter.
|
||||
You might have noticed variables wrapped in `${{ }}` in the examples. These are
|
||||
template strings for linking and gluing the different parts of the template
|
||||
together. All the form inputs from the `parameters` section will be available by
|
||||
using this template syntax (for example, `${{ parameters.firstName }}` inserts
|
||||
the value of `firstName` from the parameters). This is great for passing the
|
||||
values from the form into different steps and reusing these input variables.
|
||||
These template strings preserve the type of the parameter.
|
||||
|
||||
As you can see above in the `Outputs` section, `actions` and `steps` can also
|
||||
output things. You can grab that output using `steps.$stepId.output.$property`.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @backstage/app-defaults
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used.
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.8.0
|
||||
- @backstage/core-plugin-api@0.3.0
|
||||
- @backstage/core-app-api@0.2.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/app-defaults",
|
||||
"description": "Provides the default wiring of a Backstage App",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -29,23 +29,25 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-components": "^0.7.6",
|
||||
"@backstage/core-app-api": "^0.1.24",
|
||||
"@backstage/core-plugin-api": "^0.2.2",
|
||||
"@backstage/core-components": "^0.8.0",
|
||||
"@backstage/core-app-api": "^0.2.0",
|
||||
"@backstage/core-plugin-api": "^0.3.0",
|
||||
"@backstage/theme": "^0.2.14",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"react": "^16.12.0",
|
||||
"react-router-dom": "6.0.0-beta.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/test-utils": "^0.1.22",
|
||||
"@backstage/cli": "^0.10.1",
|
||||
"@backstage/test-utils": "^0.1.24",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react": "*"
|
||||
"@types/react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# example-app
|
||||
|
||||
## 0.2.56
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.8.0
|
||||
- @backstage/core-plugin-api@0.3.0
|
||||
- @backstage/plugin-cloudbuild@0.2.29
|
||||
- @backstage/plugin-catalog@0.7.4
|
||||
- @backstage/core-app-api@0.2.0
|
||||
- @backstage/plugin-scaffolder@0.11.14
|
||||
- @backstage/plugin-kubernetes@0.5.0
|
||||
- @backstage/app-defaults@0.1.2
|
||||
- @backstage/integration-react@0.1.15
|
||||
- @backstage/plugin-api-docs@0.6.18
|
||||
- @backstage/plugin-azure-devops@0.1.6
|
||||
- @backstage/plugin-badges@0.2.16
|
||||
- @backstage/plugin-catalog-graph@0.2.3
|
||||
- @backstage/plugin-catalog-import@0.7.5
|
||||
- @backstage/plugin-catalog-react@0.6.5
|
||||
- @backstage/plugin-circleci@0.2.31
|
||||
- @backstage/plugin-code-coverage@0.1.19
|
||||
- @backstage/plugin-cost-insights@0.11.13
|
||||
- @backstage/plugin-explore@0.3.22
|
||||
- @backstage/plugin-gcp-projects@0.3.10
|
||||
- @backstage/plugin-github-actions@0.4.26
|
||||
- @backstage/plugin-graphiql@0.2.24
|
||||
- @backstage/plugin-home@0.4.7
|
||||
- @backstage/plugin-jenkins@0.5.14
|
||||
- @backstage/plugin-kafka@0.2.22
|
||||
- @backstage/plugin-lighthouse@0.2.31
|
||||
- @backstage/plugin-newrelic@0.3.10
|
||||
- @backstage/plugin-org@0.3.30
|
||||
- @backstage/plugin-pagerduty@0.3.19
|
||||
- @backstage/plugin-rollbar@0.3.20
|
||||
- @backstage/plugin-search@0.5.1
|
||||
- @backstage/plugin-sentry@0.3.30
|
||||
- @backstage/plugin-shortcuts@0.1.15
|
||||
- @backstage/plugin-tech-insights@0.1.1
|
||||
- @backstage/plugin-tech-radar@0.4.13
|
||||
- @backstage/plugin-techdocs@0.12.9
|
||||
- @backstage/plugin-todo@0.1.16
|
||||
- @backstage/plugin-user-settings@0.3.13
|
||||
- @backstage/cli@0.10.1
|
||||
|
||||
## 0.2.55
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+43
-44
@@ -1,63 +1,62 @@
|
||||
{
|
||||
"name": "example-app",
|
||||
"version": "0.2.55",
|
||||
"version": "0.2.56",
|
||||
"private": true,
|
||||
"bundled": true,
|
||||
"dependencies": {
|
||||
"@backstage/app-defaults": "^0.1.1",
|
||||
"@backstage/app-defaults": "^0.1.2",
|
||||
"@backstage/catalog-model": "^0.9.7",
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/core-app-api": "^0.1.24",
|
||||
"@backstage/core-components": "^0.7.6",
|
||||
"@backstage/core-plugin-api": "^0.2.2",
|
||||
"@backstage/integration-react": "^0.1.14",
|
||||
"@backstage/plugin-api-docs": "^0.6.16",
|
||||
"@backstage/plugin-azure-devops": "^0.1.5",
|
||||
"@backstage/plugin-badges": "^0.2.14",
|
||||
"@backstage/plugin-catalog": "^0.7.3",
|
||||
"@backstage/plugin-catalog-graph": "^0.2.2",
|
||||
"@backstage/plugin-catalog-import": "^0.7.4",
|
||||
"@backstage/plugin-catalog-react": "^0.6.4",
|
||||
"@backstage/plugin-circleci": "^0.2.30",
|
||||
"@backstage/plugin-cloudbuild": "^0.2.28",
|
||||
"@backstage/plugin-code-coverage": "^0.1.18",
|
||||
"@backstage/plugin-cost-insights": "^0.11.11",
|
||||
"@backstage/plugin-explore": "^0.3.21",
|
||||
"@backstage/plugin-gcp-projects": "^0.3.9",
|
||||
"@backstage/plugin-github-actions": "^0.4.24",
|
||||
"@backstage/plugin-graphiql": "^0.2.21",
|
||||
"@backstage/plugin-home": "^0.4.6",
|
||||
"@backstage/plugin-jenkins": "^0.5.12",
|
||||
"@backstage/plugin-kafka": "^0.2.21",
|
||||
"@backstage/plugin-kubernetes": "^0.4.22",
|
||||
"@backstage/plugin-lighthouse": "^0.2.30",
|
||||
"@backstage/plugin-newrelic": "^0.3.9",
|
||||
"@backstage/plugin-org": "^0.3.28",
|
||||
"@backstage/plugin-pagerduty": "0.3.18",
|
||||
"@backstage/plugin-rollbar": "^0.3.19",
|
||||
"@backstage/plugin-scaffolder": "^0.11.13",
|
||||
"@backstage/plugin-search": "^0.5.0",
|
||||
"@backstage/plugin-sentry": "^0.3.29",
|
||||
"@backstage/plugin-shortcuts": "^0.1.14",
|
||||
"@backstage/plugin-tech-radar": "^0.4.12",
|
||||
"@backstage/plugin-techdocs": "^0.12.8",
|
||||
"@backstage/plugin-todo": "^0.1.15",
|
||||
"@backstage/plugin-user-settings": "^0.3.12",
|
||||
"@backstage/cli": "^0.10.1",
|
||||
"@backstage/core-app-api": "^0.2.0",
|
||||
"@backstage/core-components": "^0.8.0",
|
||||
"@backstage/core-plugin-api": "^0.3.0",
|
||||
"@backstage/integration-react": "^0.1.15",
|
||||
"@backstage/plugin-api-docs": "^0.6.18",
|
||||
"@backstage/plugin-azure-devops": "^0.1.6",
|
||||
"@backstage/plugin-badges": "^0.2.16",
|
||||
"@backstage/plugin-catalog": "^0.7.4",
|
||||
"@backstage/plugin-catalog-graph": "^0.2.3",
|
||||
"@backstage/plugin-catalog-import": "^0.7.5",
|
||||
"@backstage/plugin-catalog-react": "^0.6.5",
|
||||
"@backstage/plugin-circleci": "^0.2.31",
|
||||
"@backstage/plugin-cloudbuild": "^0.2.29",
|
||||
"@backstage/plugin-code-coverage": "^0.1.19",
|
||||
"@backstage/plugin-cost-insights": "^0.11.13",
|
||||
"@backstage/plugin-explore": "^0.3.22",
|
||||
"@backstage/plugin-gcp-projects": "^0.3.10",
|
||||
"@backstage/plugin-github-actions": "^0.4.26",
|
||||
"@backstage/plugin-graphiql": "^0.2.24",
|
||||
"@backstage/plugin-home": "^0.4.7",
|
||||
"@backstage/plugin-jenkins": "^0.5.14",
|
||||
"@backstage/plugin-kafka": "^0.2.22",
|
||||
"@backstage/plugin-kubernetes": "^0.5.0",
|
||||
"@backstage/plugin-lighthouse": "^0.2.31",
|
||||
"@backstage/plugin-newrelic": "^0.3.10",
|
||||
"@backstage/plugin-org": "^0.3.30",
|
||||
"@backstage/plugin-pagerduty": "0.3.19",
|
||||
"@backstage/plugin-rollbar": "^0.3.20",
|
||||
"@backstage/plugin-scaffolder": "^0.11.14",
|
||||
"@backstage/plugin-search": "^0.5.1",
|
||||
"@backstage/plugin-sentry": "^0.3.30",
|
||||
"@backstage/plugin-shortcuts": "^0.1.15",
|
||||
"@backstage/plugin-tech-radar": "^0.4.13",
|
||||
"@backstage/plugin-techdocs": "^0.12.9",
|
||||
"@backstage/plugin-todo": "^0.1.16",
|
||||
"@backstage/plugin-user-settings": "^0.3.13",
|
||||
"@backstage/search-common": "^0.2.0",
|
||||
"@backstage/plugin-tech-insights": "^0.1.0",
|
||||
"@backstage/plugin-tech-insights": "^0.1.1",
|
||||
"@backstage/theme": "^0.2.14",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@octokit/rest": "^18.5.3",
|
||||
"@roadiehq/backstage-plugin-buildkite": "^1.0.8",
|
||||
"@roadiehq/backstage-plugin-github-insights": "^1.1.23",
|
||||
"@roadiehq/backstage-plugin-github-pull-requests": "^1.0.13",
|
||||
"@roadiehq/backstage-plugin-travis-ci": "^1.0.11",
|
||||
"history": "^5.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
@@ -65,7 +64,7 @@
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/test-utils": "^0.1.22",
|
||||
"@backstage/test-utils": "^0.1.24",
|
||||
"@rjsf/core": "^3.2.1",
|
||||
"@testing-library/cypress": "^8.0.2",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
|
||||
@@ -106,10 +106,7 @@ import { EntityTechdocsContent } from '@backstage/plugin-techdocs';
|
||||
import { EntityTodoContent } from '@backstage/plugin-todo';
|
||||
import { Button, Grid } from '@material-ui/core';
|
||||
import BadgeIcon from '@material-ui/icons/CallToAction';
|
||||
import {
|
||||
EntityBuildkiteContent,
|
||||
isBuildkiteAvailable,
|
||||
} from '@roadiehq/backstage-plugin-buildkite';
|
||||
|
||||
import {
|
||||
EntityGithubInsightsContent,
|
||||
EntityGithubInsightsLanguagesCard,
|
||||
@@ -168,10 +165,6 @@ export const cicdContent = (
|
||||
<EntityJenkinsContent />
|
||||
</EntitySwitch.Case>
|
||||
|
||||
<EntitySwitch.Case if={isBuildkiteAvailable}>
|
||||
<EntityBuildkiteContent />
|
||||
</EntitySwitch.Case>
|
||||
|
||||
<EntitySwitch.Case if={isCircleCIAvailable}>
|
||||
<EntityCircleCIContent />
|
||||
</EntitySwitch.Case>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @backstage/backend-common
|
||||
|
||||
## 0.9.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly
|
||||
- 5a008576c4: Add possibility to use custom error handler
|
||||
- 98a9c35f0c: Add options argument to support additional database migrations configuration
|
||||
- 6298de32dd: Add knexConfig config section
|
||||
|
||||
## 0.9.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -175,23 +175,22 @@ export function createStatusCheckRouter(options: {
|
||||
// @public (undocumented)
|
||||
export class DatabaseManager {
|
||||
forPlugin(pluginId: string): PluginDatabaseManager;
|
||||
static fromConfig(config: Config): DatabaseManager;
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: DatabaseManagerOptions,
|
||||
): DatabaseManager;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type DatabaseManagerOptions = {
|
||||
migrations?: PluginDatabaseManager['migrations'];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export class DockerContainerRunner implements ContainerRunner {
|
||||
constructor({ dockerClient }: { dockerClient: Docker });
|
||||
constructor(options: { dockerClient: Docker });
|
||||
// (undocumented)
|
||||
runContainer({
|
||||
imageName,
|
||||
command,
|
||||
args,
|
||||
logStream,
|
||||
mountDirs,
|
||||
workingDir,
|
||||
envVars,
|
||||
pullImage,
|
||||
}: RunContainerOptions): Promise<void>;
|
||||
runContainer(options: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -227,34 +226,17 @@ export function getVoidLogger(): winston.Logger;
|
||||
// @public (undocumented)
|
||||
export class Git {
|
||||
// (undocumented)
|
||||
add({ dir, filepath }: { dir: string; filepath: string }): Promise<void>;
|
||||
add(options: { dir: string; filepath: string }): Promise<void>;
|
||||
// (undocumented)
|
||||
addRemote({
|
||||
dir,
|
||||
url,
|
||||
remote,
|
||||
}: {
|
||||
addRemote(options: {
|
||||
dir: string;
|
||||
remote: string;
|
||||
url: string;
|
||||
}): Promise<void>;
|
||||
// (undocumented)
|
||||
clone({
|
||||
url,
|
||||
dir,
|
||||
ref,
|
||||
}: {
|
||||
url: string;
|
||||
dir: string;
|
||||
ref?: string;
|
||||
}): Promise<void>;
|
||||
clone(options: { url: string; dir: string; ref?: string }): Promise<void>;
|
||||
// (undocumented)
|
||||
commit({
|
||||
dir,
|
||||
message,
|
||||
author,
|
||||
committer,
|
||||
}: {
|
||||
commit(options: {
|
||||
dir: string;
|
||||
message: string;
|
||||
author: {
|
||||
@@ -267,41 +249,22 @@ export class Git {
|
||||
};
|
||||
}): Promise<string>;
|
||||
// (undocumented)
|
||||
currentBranch({
|
||||
dir,
|
||||
fullName,
|
||||
}: {
|
||||
currentBranch(options: {
|
||||
dir: string;
|
||||
fullName?: boolean;
|
||||
}): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
fetch({ dir, remote }: { dir: string; remote?: string }): Promise<void>;
|
||||
fetch(options: { dir: string; remote?: string }): Promise<void>;
|
||||
// (undocumented)
|
||||
static fromAuth: ({
|
||||
username,
|
||||
password,
|
||||
logger,
|
||||
}: {
|
||||
username?: string | undefined;
|
||||
password?: string | undefined;
|
||||
logger?: Logger_2 | undefined;
|
||||
static fromAuth: (options: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
logger?: Logger_2;
|
||||
}) => Git;
|
||||
// (undocumented)
|
||||
init({
|
||||
dir,
|
||||
defaultBranch,
|
||||
}: {
|
||||
dir: string;
|
||||
defaultBranch?: string;
|
||||
}): Promise<void>;
|
||||
init(options: { dir: string; defaultBranch?: string }): Promise<void>;
|
||||
// (undocumented)
|
||||
merge({
|
||||
dir,
|
||||
theirs,
|
||||
ours,
|
||||
author,
|
||||
committer,
|
||||
}: {
|
||||
merge(options: {
|
||||
dir: string;
|
||||
theirs: string;
|
||||
ours?: string;
|
||||
@@ -315,17 +278,11 @@ export class Git {
|
||||
};
|
||||
}): Promise<MergeResult>;
|
||||
// (undocumented)
|
||||
push({ dir, remote }: { dir: string; remote: string }): Promise<PushResult>;
|
||||
push(options: { dir: string; remote: string }): Promise<PushResult>;
|
||||
// (undocumented)
|
||||
readCommit({
|
||||
dir,
|
||||
sha,
|
||||
}: {
|
||||
dir: string;
|
||||
sha: string;
|
||||
}): Promise<ReadCommitResult>;
|
||||
readCommit(options: { dir: string; sha: string }): Promise<ReadCommitResult>;
|
||||
// (undocumented)
|
||||
resolveRef({ dir, ref }: { dir: string; ref: string }): Promise<string>;
|
||||
resolveRef(options: { dir: string; ref: string }): Promise<string>;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -395,6 +352,9 @@ export type PluginCacheManager = {
|
||||
// @public
|
||||
export interface PluginDatabaseManager {
|
||||
getClient(): Promise<Knex>;
|
||||
migrations?: {
|
||||
skip?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -561,6 +521,8 @@ export type ServiceBuilder = {
|
||||
setRequestLoggingHandler(
|
||||
requestLoggingHandler: RequestLoggingHandlerFactory,
|
||||
): ServiceBuilder;
|
||||
setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder;
|
||||
disableDefaultErrorHandler(): ServiceBuilder;
|
||||
start(): Promise<Server>;
|
||||
};
|
||||
|
||||
@@ -623,8 +585,8 @@ export type UrlReaderPredicateTuple = {
|
||||
|
||||
// @public
|
||||
export class UrlReaders {
|
||||
static create({ logger, config, factories }: UrlReadersOptions): UrlReader;
|
||||
static default({ logger, config, factories }: UrlReadersOptions): UrlReader;
|
||||
static create(options: UrlReadersOptions): UrlReader;
|
||||
static default(options: UrlReadersOptions): UrlReader;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -642,4 +604,8 @@ export function useHotCleanup(
|
||||
|
||||
// @public
|
||||
export function useHotMemoize<T>(_module: NodeModule, valueFactory: () => T): T;
|
||||
|
||||
// Warnings were encountered during analysis:
|
||||
//
|
||||
// src/database/types.d.ts:23:12 - (tsdoc-undefined-tag) The TSDoc tag "@default" is not defined in this configuration
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"version": "0.9.12",
|
||||
"version": "0.9.13",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": false,
|
||||
@@ -81,8 +81,8 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/test-utils": "^0.1.23",
|
||||
"@backstage/cli": "^0.10.1",
|
||||
"@backstage/test-utils": "^0.1.24",
|
||||
"@types/archiver": "^5.1.0",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/concat-stream": "^1.6.0",
|
||||
|
||||
@@ -36,25 +36,45 @@ describe('DatabaseManager', () => {
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('DatabaseManager.fromConfig', () => {
|
||||
it('accesses the backend.database key', () => {
|
||||
const config = new ConfigReader({
|
||||
backend: {
|
||||
database: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: 'localhost',
|
||||
user: 'foo',
|
||||
password: 'bar',
|
||||
database: 'foodb',
|
||||
},
|
||||
const backendConfig = {
|
||||
backend: {
|
||||
database: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: 'localhost',
|
||||
user: 'foo',
|
||||
password: 'bar',
|
||||
database: 'foodb',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
it('accesses the backend.database key', () => {
|
||||
const config = new ConfigReader(backendConfig);
|
||||
const getConfigSpy = jest.spyOn(config, 'getConfig');
|
||||
DatabaseManager.fromConfig(config);
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledWith('backend.database');
|
||||
});
|
||||
|
||||
it('handles default options', () => {
|
||||
const config = new ConfigReader(backendConfig);
|
||||
const database = DatabaseManager.fromConfig(config);
|
||||
const client = database.forPlugin('test');
|
||||
|
||||
expect(client.migrations?.skip).toBe(false);
|
||||
});
|
||||
|
||||
it('handles migrations options', () => {
|
||||
const config = new ConfigReader(backendConfig);
|
||||
const database = DatabaseManager.fromConfig(config, {
|
||||
migrations: { skip: true },
|
||||
});
|
||||
const client = database.forPlugin('test');
|
||||
|
||||
expect(client.migrations?.skip).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseManager.forPlugin', () => {
|
||||
|
||||
@@ -36,6 +36,15 @@ function pluginPath(pluginId: string): string {
|
||||
return `plugin.${pluginId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options object.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type DatabaseManagerOptions = {
|
||||
migrations?: PluginDatabaseManager['migrations'];
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export class DatabaseManager {
|
||||
/**
|
||||
@@ -47,19 +56,25 @@ export class DatabaseManager {
|
||||
* names if config is not provided.
|
||||
*
|
||||
* @param config - The loaded application configuration.
|
||||
* @param options - An optional configuration object.
|
||||
*/
|
||||
static fromConfig(config: Config): DatabaseManager {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: DatabaseManagerOptions,
|
||||
): DatabaseManager {
|
||||
const databaseConfig = config.getConfig('backend.database');
|
||||
|
||||
return new DatabaseManager(
|
||||
databaseConfig,
|
||||
databaseConfig.getOptionalString('prefix'),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly config: Config,
|
||||
private readonly prefix: string = 'backstage_plugin_',
|
||||
private readonly options?: DatabaseManagerOptions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -76,6 +91,10 @@ export class DatabaseManager {
|
||||
getClient(): Promise<Knex> {
|
||||
return _this.getDatabase(pluginId);
|
||||
},
|
||||
migrations: {
|
||||
skip: false,
|
||||
..._this.options?.migrations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ export interface PluginDatabaseManager {
|
||||
* stores so that plugins are discouraged from database integration.
|
||||
*/
|
||||
getClient(): Promise<Knex>;
|
||||
|
||||
/**
|
||||
* This property is used to control the behavior of database migrations.
|
||||
*/
|
||||
migrations?: {
|
||||
/**
|
||||
* skip database migrations. Useful if connecting to a read-only database.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
skip?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,7 +46,8 @@ export class UrlReaders {
|
||||
/**
|
||||
* Creates a UrlReader without any known types.
|
||||
*/
|
||||
static create({ logger, config, factories }: UrlReadersOptions): UrlReader {
|
||||
static create(options: UrlReadersOptions): UrlReader {
|
||||
const { logger, config, factories } = options;
|
||||
const mux = new UrlReaderPredicateMux(logger);
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config,
|
||||
@@ -68,7 +69,8 @@ export class UrlReaders {
|
||||
*
|
||||
* Any additional factories passed will be loaded before the default ones.
|
||||
*/
|
||||
static default({ logger, config, factories = [] }: UrlReadersOptions) {
|
||||
static default(options: UrlReadersOptions) {
|
||||
const { logger, config, factories = [] } = options;
|
||||
return UrlReaders.create({
|
||||
logger,
|
||||
config,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import git, {
|
||||
ProgressCallback,
|
||||
MergeResult,
|
||||
@@ -42,44 +43,32 @@ export class Git {
|
||||
},
|
||||
) {}
|
||||
|
||||
async add({
|
||||
dir,
|
||||
filepath,
|
||||
}: {
|
||||
dir: string;
|
||||
filepath: string;
|
||||
}): Promise<void> {
|
||||
async add(options: { dir: string; filepath: string }): Promise<void> {
|
||||
const { dir, filepath } = options;
|
||||
this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`);
|
||||
|
||||
return git.add({ fs, dir, filepath });
|
||||
}
|
||||
|
||||
async addRemote({
|
||||
dir,
|
||||
url,
|
||||
remote,
|
||||
}: {
|
||||
async addRemote(options: {
|
||||
dir: string;
|
||||
remote: string;
|
||||
url: string;
|
||||
}): Promise<void> {
|
||||
const { dir, url, remote } = options;
|
||||
this.config.logger?.info(
|
||||
`Creating new remote {dir=${dir},remote=${remote},url=${url}}`,
|
||||
);
|
||||
return git.addRemote({ fs, dir, remote, url });
|
||||
}
|
||||
|
||||
async commit({
|
||||
dir,
|
||||
message,
|
||||
author,
|
||||
committer,
|
||||
}: {
|
||||
async commit(options: {
|
||||
dir: string;
|
||||
message: string;
|
||||
author: { name: string; email: string };
|
||||
committer: { name: string; email: string };
|
||||
}): Promise<string> {
|
||||
const { dir, message, author, committer } = options;
|
||||
this.config.logger?.info(
|
||||
`Committing file to repo {dir=${dir},message=${message}}`,
|
||||
);
|
||||
@@ -87,15 +76,12 @@ export class Git {
|
||||
return git.commit({ fs, dir, message, author, committer });
|
||||
}
|
||||
|
||||
async clone({
|
||||
url,
|
||||
dir,
|
||||
ref,
|
||||
}: {
|
||||
async clone(options: {
|
||||
url: string;
|
||||
dir: string;
|
||||
ref?: string;
|
||||
}): Promise<void> {
|
||||
const { url, dir, ref } = options;
|
||||
this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
|
||||
return git.clone({
|
||||
fs,
|
||||
@@ -114,51 +100,35 @@ export class Git {
|
||||
}
|
||||
|
||||
// https://isomorphic-git.org/docs/en/currentBranch
|
||||
async currentBranch({
|
||||
dir,
|
||||
fullName,
|
||||
}: {
|
||||
async currentBranch(options: {
|
||||
dir: string;
|
||||
fullName?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const fullname = fullName ?? false;
|
||||
return git.currentBranch({ fs, dir, fullname }) as Promise<
|
||||
const { dir, fullName = false } = options;
|
||||
return git.currentBranch({ fs, dir, fullname: fullName }) as Promise<
|
||||
string | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
// https://isomorphic-git.org/docs/en/fetch
|
||||
async fetch({
|
||||
dir,
|
||||
remote,
|
||||
}: {
|
||||
dir: string;
|
||||
remote?: string;
|
||||
}): Promise<void> {
|
||||
const remoteValue = remote ?? 'origin';
|
||||
async fetch(options: { dir: string; remote?: string }): Promise<void> {
|
||||
const { dir, remote = 'origin' } = options;
|
||||
this.config.logger?.info(
|
||||
`Fetching remote=${remoteValue} for repository {dir=${dir}}`,
|
||||
`Fetching remote=${remote} for repository {dir=${dir}}`,
|
||||
);
|
||||
await git.fetch({
|
||||
fs,
|
||||
http,
|
||||
dir,
|
||||
remote: remoteValue,
|
||||
remote,
|
||||
onProgress: this.onProgressHandler(),
|
||||
headers: {
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
headers: { 'user-agent': 'git/@isomorphic-git' },
|
||||
onAuth: this.onAuth,
|
||||
});
|
||||
}
|
||||
|
||||
async init({
|
||||
dir,
|
||||
defaultBranch = 'master',
|
||||
}: {
|
||||
dir: string;
|
||||
defaultBranch?: string;
|
||||
}): Promise<void> {
|
||||
async init(options: { dir: string; defaultBranch?: string }): Promise<void> {
|
||||
const { dir, defaultBranch = 'master' } = options;
|
||||
this.config.logger?.info(`Init git repository {dir=${dir}}`);
|
||||
|
||||
return git.init({
|
||||
@@ -169,19 +139,14 @@ export class Git {
|
||||
}
|
||||
|
||||
// https://isomorphic-git.org/docs/en/merge
|
||||
async merge({
|
||||
dir,
|
||||
theirs,
|
||||
ours,
|
||||
author,
|
||||
committer,
|
||||
}: {
|
||||
async merge(options: {
|
||||
dir: string;
|
||||
theirs: string;
|
||||
ours?: string;
|
||||
author: { name: string; email: string };
|
||||
committer: { name: string; email: string };
|
||||
}): Promise<MergeResult> {
|
||||
const { dir, theirs, ours, author, committer } = options;
|
||||
this.config.logger?.info(
|
||||
`Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`,
|
||||
);
|
||||
@@ -197,7 +162,8 @@ export class Git {
|
||||
});
|
||||
}
|
||||
|
||||
async push({ dir, remote }: { dir: string; remote: string }) {
|
||||
async push(options: { dir: string; remote: string }) {
|
||||
const { dir, remote } = options;
|
||||
this.config.logger?.info(
|
||||
`Pushing directory to remote {dir=${dir},remote=${remote}}`,
|
||||
);
|
||||
@@ -215,24 +181,17 @@ export class Git {
|
||||
}
|
||||
|
||||
// https://isomorphic-git.org/docs/en/readCommit
|
||||
async readCommit({
|
||||
dir,
|
||||
sha,
|
||||
}: {
|
||||
async readCommit(options: {
|
||||
dir: string;
|
||||
sha: string;
|
||||
}): Promise<ReadCommitResult> {
|
||||
const { dir, sha } = options;
|
||||
return git.readCommit({ fs, dir, oid: sha });
|
||||
}
|
||||
|
||||
// https://isomorphic-git.org/docs/en/resolveRef
|
||||
async resolveRef({
|
||||
dir,
|
||||
ref,
|
||||
}: {
|
||||
dir: string;
|
||||
ref: string;
|
||||
}): Promise<string> {
|
||||
async resolveRef(options: { dir: string; ref: string }): Promise<string> {
|
||||
const { dir, ref } = options;
|
||||
return git.resolveRef({ fs, dir, ref });
|
||||
}
|
||||
|
||||
@@ -256,13 +215,12 @@ export class Git {
|
||||
};
|
||||
};
|
||||
|
||||
static fromAuth = ({
|
||||
username,
|
||||
password,
|
||||
logger,
|
||||
}: {
|
||||
static fromAuth = (options: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
logger?: Logger;
|
||||
}) => new Git({ username, password, logger });
|
||||
}) => {
|
||||
const { username, password, logger } = options;
|
||||
return new Git({ username, password, logger });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { applyCspDirectives } from './ServiceBuilderImpl';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { applyCspDirectives, ServiceBuilderImpl } from './ServiceBuilderImpl';
|
||||
|
||||
describe('ServiceBuilderImpl', () => {
|
||||
describe('applyCspDirectives', () => {
|
||||
@@ -33,4 +34,27 @@ describe('ServiceBuilderImpl', () => {
|
||||
expect(result!['upgrade-insecure-requests']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCustomErrorHandler', () => {
|
||||
it('check if custom error handler is undefined', () => {
|
||||
const serviceBuilder = new ServiceBuilderImpl(module);
|
||||
const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder);
|
||||
expect(serviceBuilderProto.errorHandler).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds custom error handler', () => {
|
||||
const serviceBuilder = new ServiceBuilderImpl(module);
|
||||
const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder);
|
||||
const customErrorHandler = (
|
||||
error: Error,
|
||||
_req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
next(error);
|
||||
};
|
||||
serviceBuilderProto.setErrorHandler(customErrorHandler);
|
||||
expect(serviceBuilderProto.errorHandler).toEqual(customErrorHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { Config } from '@backstage/config';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express, { Router } from 'express';
|
||||
import express, { Router, ErrorRequestHandler } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import * as http from 'http';
|
||||
import stoppable from 'stoppable';
|
||||
@@ -25,7 +25,7 @@ import { Logger } from 'winston';
|
||||
import { useHotCleanup } from '../../hot';
|
||||
import { getRootLogger } from '../../logging';
|
||||
import {
|
||||
errorHandler,
|
||||
errorHandler as defaultErrorHandler,
|
||||
notFoundHandler,
|
||||
requestLoggingHandler as defaultRequestLoggingHandler,
|
||||
} from '../../middleware';
|
||||
@@ -66,6 +66,8 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
private httpsSettings: HttpsSettings | undefined;
|
||||
private routers: [string, Router][];
|
||||
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
|
||||
private errorHandler: ErrorRequestHandler | undefined;
|
||||
private useDefaultErrorHandler: boolean;
|
||||
// Reference to the module where builder is created - needed for hot module
|
||||
// reloading
|
||||
private module: NodeModule;
|
||||
@@ -73,6 +75,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
constructor(moduleRef: NodeModule) {
|
||||
this.routers = [];
|
||||
this.module = moduleRef;
|
||||
this.useDefaultErrorHandler = true;
|
||||
}
|
||||
|
||||
loadConfig(config: Config): ServiceBuilder {
|
||||
@@ -152,6 +155,16 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
setErrorHandler(errorHandler: ErrorRequestHandler) {
|
||||
this.errorHandler = errorHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
disableDefaultErrorHandler() {
|
||||
this.useDefaultErrorHandler = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
async start(): Promise<http.Server> {
|
||||
const app = express();
|
||||
const { port, host, logger, corsOptions, httpsSettings, helmetOptions } =
|
||||
@@ -169,7 +182,14 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
app.use(root, route);
|
||||
}
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
if (this.errorHandler) {
|
||||
app.use(this.errorHandler);
|
||||
}
|
||||
|
||||
if (this.useDefaultErrorHandler) {
|
||||
app.use(defaultErrorHandler());
|
||||
}
|
||||
|
||||
const server: http.Server = httpsSettings
|
||||
? await createHttpsServer(app, httpsSettings, logger)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import cors from 'cors';
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { Router, RequestHandler, ErrorRequestHandler } from 'express';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
@@ -98,6 +98,21 @@ export type ServiceBuilder = {
|
||||
requestLoggingHandler: RequestLoggingHandlerFactory,
|
||||
): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Sets an additional errorHandler to run before the defaultErrorHandler.
|
||||
*
|
||||
* For execution of only the custom error handler make sure to also invoke disableDefaultErrorHandler()
|
||||
* otherwise the defaultErrorHandler is executed at the end of the error middleware chain.
|
||||
*
|
||||
* @param errorHandler - an error handler
|
||||
*/
|
||||
setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Disables the default error handler
|
||||
*/
|
||||
disableDefaultErrorHandler(): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Starts the server using the given settings.
|
||||
*/
|
||||
|
||||
@@ -28,20 +28,22 @@ export type UserOptions = {
|
||||
export class DockerContainerRunner implements ContainerRunner {
|
||||
private readonly dockerClient: Docker;
|
||||
|
||||
constructor({ dockerClient }: { dockerClient: Docker }) {
|
||||
this.dockerClient = dockerClient;
|
||||
constructor(options: { dockerClient: Docker }) {
|
||||
this.dockerClient = options.dockerClient;
|
||||
}
|
||||
|
||||
async runContainer({
|
||||
imageName,
|
||||
command,
|
||||
args,
|
||||
logStream = new PassThrough(),
|
||||
mountDirs = {},
|
||||
workingDir,
|
||||
envVars = {},
|
||||
pullImage = true,
|
||||
}: RunContainerOptions) {
|
||||
async runContainer(options: RunContainerOptions) {
|
||||
const {
|
||||
imageName,
|
||||
command,
|
||||
args,
|
||||
logStream = new PassThrough(),
|
||||
mountDirs = {},
|
||||
workingDir,
|
||||
envVars = {},
|
||||
pullImage = true,
|
||||
} = options;
|
||||
|
||||
// Show a better error message when Docker is unavailable.
|
||||
try {
|
||||
await this.dockerClient.ping();
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# example-backend
|
||||
|
||||
## 0.2.56
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-auth-backend@0.5.0
|
||||
- @backstage/plugin-scaffolder-backend@0.15.16
|
||||
- @backstage/plugin-kubernetes-backend@0.4.0
|
||||
- @backstage/backend-common@0.9.13
|
||||
- @backstage/plugin-catalog-backend@0.19.1
|
||||
- @backstage/plugin-search-backend@0.2.8
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@0.0.6
|
||||
- @backstage/plugin-search-backend-module-pg@0.2.2
|
||||
- @backstage/plugin-techdocs-backend@0.12.0
|
||||
- @backstage/plugin-todo-backend@0.1.15
|
||||
- @backstage/plugin-scaffolder-backend-module-rails@0.2.0
|
||||
- @backstage/plugin-azure-devops-backend@0.2.4
|
||||
- example-app@0.2.56
|
||||
|
||||
## 0.2.55
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-backend",
|
||||
"version": "0.2.55",
|
||||
"version": "0.2.56",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -24,39 +24,39 @@
|
||||
"migrate:create": "knex migrate:make -x ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.12",
|
||||
"@backstage/backend-common": "^0.9.13",
|
||||
"@backstage/catalog-client": "^0.5.2",
|
||||
"@backstage/catalog-model": "^0.9.7",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/integration": "^0.6.10",
|
||||
"@backstage/plugin-app-backend": "^0.3.19",
|
||||
"@backstage/plugin-auth-backend": "^0.4.10",
|
||||
"@backstage/plugin-azure-devops-backend": "^0.2.3",
|
||||
"@backstage/plugin-auth-backend": "^0.5.0",
|
||||
"@backstage/plugin-azure-devops-backend": "^0.2.4",
|
||||
"@backstage/plugin-badges-backend": "^0.1.13",
|
||||
"@backstage/plugin-catalog-backend": "^0.19.0",
|
||||
"@backstage/plugin-catalog-backend": "^0.19.1",
|
||||
"@backstage/plugin-code-coverage-backend": "^0.1.16",
|
||||
"@backstage/plugin-graphql-backend": "^0.1.9",
|
||||
"@backstage/plugin-jenkins-backend": "^0.1.9",
|
||||
"@backstage/plugin-kubernetes-backend": "^0.3.20",
|
||||
"@backstage/plugin-kubernetes-backend": "^0.4.0",
|
||||
"@backstage/plugin-kafka-backend": "^0.2.12",
|
||||
"@backstage/plugin-proxy-backend": "^0.2.14",
|
||||
"@backstage/plugin-rollbar-backend": "^0.1.16",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.15.15",
|
||||
"@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7",
|
||||
"@backstage/plugin-search-backend": "^0.2.7",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.15.16",
|
||||
"@backstage/plugin-scaffolder-backend-module-rails": "^0.2.0",
|
||||
"@backstage/plugin-search-backend": "^0.2.8",
|
||||
"@backstage/plugin-search-backend-node": "^0.4.2",
|
||||
"@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5",
|
||||
"@backstage/plugin-search-backend-module-pg": "^0.2.1",
|
||||
"@backstage/plugin-techdocs-backend": "^0.11.0",
|
||||
"@backstage/plugin-search-backend-module-elasticsearch": "^0.0.6",
|
||||
"@backstage/plugin-search-backend-module-pg": "^0.2.2",
|
||||
"@backstage/plugin-techdocs-backend": "^0.12.0",
|
||||
"@backstage/plugin-tech-insights-backend": "^0.1.3",
|
||||
"@backstage/plugin-tech-insights-node": "^0.1.1",
|
||||
"@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.2",
|
||||
"@backstage/plugin-todo-backend": "^0.1.14",
|
||||
"@backstage/plugin-todo-backend": "^0.1.15",
|
||||
"@gitbeaker/node": "^34.6.0",
|
||||
"@octokit/rest": "^18.5.3",
|
||||
"azure-devops-node-api": "^11.0.1",
|
||||
"dockerode": "^3.3.1",
|
||||
"example-app": "^0.2.55",
|
||||
"example-app": "^0.2.56",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"express-prom-bundle": "^6.3.6",
|
||||
@@ -68,7 +68,7 @@
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/cli": "^0.10.1",
|
||||
"@types/dockerode": "^3.3.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-serve-static-core": "^4.17.5"
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @backstage/cli
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0ebb05eee2: Add cli option to minify the generated code of a plugin or backend package
|
||||
|
||||
```
|
||||
backstage-cli plugin:build --minify
|
||||
backstage-cli backend:build --minify
|
||||
```
|
||||
|
||||
- cd450844f6: Updated the frontend plugin template to put React dependencies in `peerDependencies` by default, as well as allowing both React v16 and v17. This change can be applied to existing plugins by running `yarn backstage-cli plugin:diff` within the plugin package directory.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -89,7 +89,6 @@
|
||||
"ora": "^5.3.0",
|
||||
"postcss": "^8.1.0",
|
||||
"process": "^0.11.10",
|
||||
"react": "^16.0.0",
|
||||
"react-dev-utils": "^12.0.0-next.47",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
@@ -117,13 +116,13 @@
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "^0.9.12",
|
||||
"@backstage/backend-common": "^0.9.13",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/core-components": "^0.7.6",
|
||||
"@backstage/core-plugin-api": "^0.2.2",
|
||||
"@backstage/core-app-api": "^0.1.24",
|
||||
"@backstage/dev-utils": "^0.2.13",
|
||||
"@backstage/test-utils": "^0.1.23",
|
||||
"@backstage/core-components": "^0.8.0",
|
||||
"@backstage/core-plugin-api": "^0.3.0",
|
||||
"@backstage/core-app-api": "^0.2.0",
|
||||
"@backstage/dev-utils": "^0.2.14",
|
||||
"@backstage/test-utils": "^0.1.24",
|
||||
"@backstage/theme": "^0.2.14",
|
||||
"@types/diff": "^5.0.0",
|
||||
"@types/express": "^4.17.6",
|
||||
|
||||
@@ -75,7 +75,9 @@ class PackageJsonHandler {
|
||||
await this.syncScripts();
|
||||
await this.syncPublishConfig();
|
||||
await this.syncDependencies('dependencies');
|
||||
await this.syncDependencies('peerDependencies', true);
|
||||
await this.syncDependencies('devDependencies');
|
||||
await this.syncReactDeps();
|
||||
}
|
||||
|
||||
// Make sure a field inside package.json is in sync. This mutates the targetObj and writes package.json on change.
|
||||
@@ -207,12 +209,12 @@ class PackageJsonHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncDependencies(fieldName: string) {
|
||||
private async syncDependencies(fieldName: string, required: boolean = false) {
|
||||
const pkgDeps = this.pkg[fieldName];
|
||||
const targetDeps = (this.targetPkg[fieldName] =
|
||||
this.targetPkg[fieldName] || {});
|
||||
|
||||
if (!pkgDeps) {
|
||||
if (!pkgDeps && !required) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -231,10 +233,26 @@ class PackageJsonHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.syncField(key, pkgDeps, targetDeps, fieldName, true, true);
|
||||
await this.syncField(
|
||||
key,
|
||||
pkgDeps,
|
||||
targetDeps,
|
||||
fieldName,
|
||||
true,
|
||||
!required,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncReactDeps() {
|
||||
const targetDeps = (this.targetPkg.dependencies =
|
||||
this.targetPkg.dependencies || {});
|
||||
|
||||
// Remove these from from deps since they're now in peerDeps
|
||||
await this.syncField('react', {}, targetDeps, 'dependencies');
|
||||
await this.syncField('react-dom', {}, targetDeps, 'dependencies');
|
||||
}
|
||||
|
||||
private async write() {
|
||||
await this.writeFunc(`${JSON.stringify(this.targetPkg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const version = findVersion();
|
||||
export const isDev = fs.pathExistsSync(paths.resolveOwn('src'));
|
||||
|
||||
export function createPackageVersionProvider(lockfile?: Lockfile) {
|
||||
return (name: string, versionHint?: string) => {
|
||||
return (name: string, versionHint?: string): string => {
|
||||
const packageVersion = packageVersions[name];
|
||||
const targetVersion = versionHint || packageVersion;
|
||||
if (!targetVersion) {
|
||||
@@ -94,6 +94,6 @@ export function createPackageVersionProvider(lockfile?: Lockfile) {
|
||||
if (semver.parse(versionHint)?.prerelease.length) {
|
||||
return versionHint!;
|
||||
}
|
||||
return `^${versionHint}`;
|
||||
return versionHint?.match(/^[\d\.]+$/) ? `^${versionHint}` : versionHint!;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,10 +32,11 @@
|
||||
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
|
||||
"@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
|
||||
"@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.57'}}",
|
||||
"react": "{{versionQuery 'react' '16.13.1'}}",
|
||||
"react-dom": "{{versionQuery 'react-dom' '16.13.1'}}",
|
||||
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
|
||||
"@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @backstage/codemods
|
||||
|
||||
## 0.1.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.8.0
|
||||
- @backstage/core-plugin-api@0.3.0
|
||||
- @backstage/core-app-api@0.2.0
|
||||
|
||||
## 0.1.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/codemods",
|
||||
"description": "A collection of codemods for Backstage projects",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.25",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# @backstage/core-app-api
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a036b65c2f: **BREAKING CHANGE**
|
||||
|
||||
The app `SignInPage` component has been updated to switch out the `onResult` callback for a new `onSignInSuccess` callback. This is an immediate breaking change without any deprecation period, as it was deemed to be the way of making this change that had the lowest impact.
|
||||
|
||||
The new `onSignInSuccess` callback directly accepts an implementation of an `IdentityApi`, rather than a `SignInResult`. The `SignInPage` from `@backstage/core-component` has been updated to fit this new API, and as long as you pass on `props` directly you should not see any breakage.
|
||||
|
||||
However, if you implement your own custom `SignInPage`, then this will be a breaking change and you need to migrate over to using the new callback. While doing so you can take advantage of the `UserIdentity.fromLegacy` helper from `@backstage/core-components` to make the migration simpler by still using the `SignInResult` type. This helper is also deprecated though and is only provided for immediate migration. Long-term it will be necessary to build the `IdentityApi` using for example `UserIdentity.create` instead.
|
||||
|
||||
The following is an example of how you can migrate existing usage immediately using `UserIdentity.fromLegacy`:
|
||||
|
||||
```ts
|
||||
onResult(signInResult);
|
||||
// becomes
|
||||
onSignInSuccess(UserIdentity.fromLegacy(signInResult));
|
||||
```
|
||||
|
||||
The following is an example of how implement the new `onSignInSuccess` callback of the `SignInPage` using `UserIdentity.create`:
|
||||
|
||||
```ts
|
||||
const identityResponse = await authApi.getBackstageIdentity();
|
||||
// Profile is optional and will be removed, but allows the
|
||||
// synchronous getProfile method of the IdentityApi to be used.
|
||||
const profile = await authApi.getProfile();
|
||||
onSignInSuccess(
|
||||
UserIdentity.create({
|
||||
identity: identityResponse.identity,
|
||||
authApi,
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used.
|
||||
- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.8.0
|
||||
- @backstage/core-plugin-api@0.3.0
|
||||
- @backstage/app-defaults@0.1.2
|
||||
- @backstage/version-bridge@0.1.1
|
||||
|
||||
## 0.1.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -39,6 +39,7 @@ import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api';
|
||||
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { googleAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuthApi } from '@backstage/core-plugin-api';
|
||||
import { OAuthRequestApi } from '@backstage/core-plugin-api';
|
||||
@@ -250,24 +251,13 @@ export class AppThemeSelector implements AppThemeApi {
|
||||
// @public
|
||||
export class AtlassianAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
}: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class Auth0Auth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof auth0AuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -303,13 +293,7 @@ export type BackstagePluginWithAnyOutput = Omit<
|
||||
// @public
|
||||
export class BitbucketAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -402,13 +386,7 @@ export class GithubAuth implements OAuthApi, SessionApi {
|
||||
// @deprecated
|
||||
constructor(sessionManager: SessionManager<GithubSession>);
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): GithubAuth;
|
||||
static create(options: OAuthApiCreateOptions): GithubAuth;
|
||||
// (undocumented)
|
||||
getAccessToken(scope?: string, options?: AuthRequestOptions): Promise<string>;
|
||||
// (undocumented)
|
||||
@@ -441,25 +419,13 @@ export type GithubSession = {
|
||||
// @public
|
||||
export class GitlabAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class GoogleAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment,
|
||||
provider,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -477,13 +443,7 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi {
|
||||
// @public
|
||||
export class MicrosoftAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
discoveryApi,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -507,14 +467,7 @@ export class OAuth2
|
||||
scopeTransform: (scopes: string[]) => string[];
|
||||
});
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
defaultScopes,
|
||||
scopeTransform,
|
||||
}: OAuth2CreateOptions): OAuth2;
|
||||
static create(options: OAuth2CreateOptions): OAuth2;
|
||||
// (undocumented)
|
||||
getAccessToken(
|
||||
scope?: string | string[],
|
||||
@@ -570,24 +523,15 @@ export class OAuthRequestManager implements OAuthRequestApi {
|
||||
// @public
|
||||
export class OktaAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
defaultScopes,
|
||||
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T;
|
||||
static create(options: OAuthApiCreateOptions): typeof oktaAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class OneLoginAuth {
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
}: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T;
|
||||
static create(
|
||||
options: OneLoginAuthCreateOptions,
|
||||
): typeof oneloginAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -607,11 +551,7 @@ export class SamlAuth
|
||||
// @deprecated
|
||||
constructor(sessionManager: SessionManager<SamlSession>);
|
||||
// (undocumented)
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
}: AuthApiCreateOptions): SamlAuth;
|
||||
static create(options: AuthApiCreateOptions): SamlAuth;
|
||||
// (undocumented)
|
||||
getBackstageIdentity(
|
||||
options?: AuthRequestOptions,
|
||||
@@ -635,10 +575,10 @@ export type SamlSession = {
|
||||
|
||||
// @public
|
||||
export type SignInPageProps = {
|
||||
onResult(result: SignInResult): void;
|
||||
onSignInSuccess(identityApi: IdentityApi): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type SignInResult = {
|
||||
userId: string;
|
||||
profile: ProfileInfo;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/core-app-api",
|
||||
"description": "Core app API used by Backstage apps",
|
||||
"version": "0.1.24",
|
||||
"version": "0.2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -29,26 +29,28 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/app-defaults": "^0.1.1",
|
||||
"@backstage/core-components": "^0.7.6",
|
||||
"@backstage/app-defaults": "^0.1.2",
|
||||
"@backstage/core-components": "^0.8.0",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/core-plugin-api": "^0.2.2",
|
||||
"@backstage/core-plugin-api": "^0.3.0",
|
||||
"@backstage/theme": "^0.2.14",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@backstage/version-bridge": "^0.1.0",
|
||||
"@backstage/version-bridge": "^0.1.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@types/react": "*",
|
||||
"@types/prop-types": "^15.7.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/test-utils": "^0.1.23",
|
||||
"@backstage/cli": "^0.10.1",
|
||||
"@backstage/test-utils": "^0.1.24",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {
|
||||
IdentityApi,
|
||||
ProfileInfo,
|
||||
BackstageUserIdentity,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
function mkError(thing: string) {
|
||||
return new Error(
|
||||
`Tried to access IdentityApi ${thing} before app was loaded`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the connection between the App-wide IdentityApi
|
||||
* and sign-in page.
|
||||
*/
|
||||
export class AppIdentityProxy implements IdentityApi {
|
||||
private target?: IdentityApi;
|
||||
|
||||
// This is called by the app manager once the sign-in page provides us with an implementation
|
||||
setTarget(identityApi: IdentityApi) {
|
||||
this.target = identityApi;
|
||||
}
|
||||
|
||||
getUserId(): string {
|
||||
if (!this.target) {
|
||||
throw mkError('getUserId');
|
||||
}
|
||||
return this.target.getUserId();
|
||||
}
|
||||
|
||||
getProfile(): ProfileInfo {
|
||||
if (!this.target) {
|
||||
throw mkError('getProfile');
|
||||
}
|
||||
return this.target.getProfile();
|
||||
}
|
||||
|
||||
async getProfileInfo(): Promise<ProfileInfo> {
|
||||
if (!this.target) {
|
||||
throw mkError('getProfileInfo');
|
||||
}
|
||||
return this.target.getProfileInfo();
|
||||
}
|
||||
|
||||
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
|
||||
if (!this.target) {
|
||||
throw mkError('getBackstageIdentity');
|
||||
}
|
||||
return this.target.getBackstageIdentity();
|
||||
}
|
||||
|
||||
async getCredentials(): Promise<{ token?: string | undefined }> {
|
||||
if (!this.target) {
|
||||
throw mkError('getCredentials');
|
||||
}
|
||||
return this.target.getCredentials();
|
||||
}
|
||||
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
if (!this.target) {
|
||||
throw mkError('getIdToken');
|
||||
}
|
||||
return this.target.getIdToken();
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
if (!this.target) {
|
||||
throw mkError('signOut');
|
||||
}
|
||||
await this.target.signOut();
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,14 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class AtlassianAuth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
}: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -30,13 +30,15 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class Auth0Auth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['openid', `email`, `profile`],
|
||||
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['openid', `email`, `profile`],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -45,13 +45,15 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class BitbucketAuth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['team'],
|
||||
}: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['team'],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -56,13 +56,15 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class GithubAuth implements OAuthApi, SessionApi {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['read:user'],
|
||||
}: OAuthApiCreateOptions) {
|
||||
static create(options: OAuthApiCreateOptions) {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['read:user'],
|
||||
} = options;
|
||||
|
||||
const connector = new DefaultAuthConnector({
|
||||
discoveryApi,
|
||||
environment,
|
||||
|
||||
@@ -30,13 +30,15 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class GitlabAuth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['read_user'],
|
||||
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['read_user'],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -32,17 +32,19 @@ const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
* @public
|
||||
*/
|
||||
export default class GoogleAuth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
defaultScopes = [
|
||||
'openid',
|
||||
`${SCOPE_PREFIX}userinfo.email`,
|
||||
`${SCOPE_PREFIX}userinfo.profile`,
|
||||
],
|
||||
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
defaultScopes = [
|
||||
'openid',
|
||||
`${SCOPE_PREFIX}userinfo.email`,
|
||||
`${SCOPE_PREFIX}userinfo.profile`,
|
||||
],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -30,19 +30,21 @@ const DEFAULT_PROVIDER = {
|
||||
* @public
|
||||
*/
|
||||
export default class MicrosoftAuth {
|
||||
static create({
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
discoveryApi,
|
||||
defaultScopes = [
|
||||
'openid',
|
||||
'offline_access',
|
||||
'profile',
|
||||
'email',
|
||||
'User.Read',
|
||||
],
|
||||
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
|
||||
const {
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
discoveryApi,
|
||||
defaultScopes = [
|
||||
'openid',
|
||||
'offline_access',
|
||||
'profile',
|
||||
'email',
|
||||
'User.Read',
|
||||
],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -70,14 +70,16 @@ export default class OAuth2
|
||||
BackstageIdentityApi,
|
||||
SessionApi
|
||||
{
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = [],
|
||||
scopeTransform = x => x,
|
||||
}: OAuth2CreateOptions) {
|
||||
static create(options: OAuth2CreateOptions) {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = [],
|
||||
scopeTransform = x => x,
|
||||
} = options;
|
||||
|
||||
const connector = new DefaultAuthConnector({
|
||||
discoveryApi,
|
||||
environment,
|
||||
|
||||
@@ -42,13 +42,15 @@ const OKTA_SCOPE_PREFIX: string = 'okta.';
|
||||
* @public
|
||||
*/
|
||||
export default class OktaAuth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
|
||||
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
|
||||
static create(options: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -57,12 +57,16 @@ const SCOPE_PREFIX: string = 'onelogin.';
|
||||
* @public
|
||||
*/
|
||||
export default class OneLoginAuth {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
}: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T {
|
||||
static create(
|
||||
options: OneLoginAuthCreateOptions,
|
||||
): typeof oneloginAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
|
||||
@@ -52,11 +52,13 @@ const DEFAULT_PROVIDER = {
|
||||
export default class SamlAuth
|
||||
implements ProfileInfoApi, BackstageIdentityApi, SessionApi
|
||||
{
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
}: AuthApiCreateOptions) {
|
||||
static create(options: AuthApiCreateOptions) {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
} = options;
|
||||
|
||||
const connector = new DirectAuthConnector<SamlSession>({
|
||||
discoveryApi,
|
||||
environment,
|
||||
|
||||
@@ -1,85 +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 { IdentityApi, ProfileInfo } from '@backstage/core-plugin-api';
|
||||
import { SignInResult } from './types';
|
||||
|
||||
/**
|
||||
* Implementation of the connection between the App-wide IdentityApi
|
||||
* and sign-in page.
|
||||
*/
|
||||
export class AppIdentity implements IdentityApi {
|
||||
private hasIdentity = false;
|
||||
private userId?: string;
|
||||
private profile?: ProfileInfo;
|
||||
private idTokenFunc?: () => Promise<string>;
|
||||
private signOutFunc?: () => Promise<void>;
|
||||
|
||||
getUserId(): string {
|
||||
if (!this.hasIdentity) {
|
||||
throw new Error(
|
||||
'Tried to access IdentityApi userId before app was loaded',
|
||||
);
|
||||
}
|
||||
return this.userId!;
|
||||
}
|
||||
|
||||
getProfile(): ProfileInfo {
|
||||
if (!this.hasIdentity) {
|
||||
throw new Error(
|
||||
'Tried to access IdentityApi profile before app was loaded',
|
||||
);
|
||||
}
|
||||
return this.profile!;
|
||||
}
|
||||
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
if (!this.hasIdentity) {
|
||||
throw new Error(
|
||||
'Tried to access IdentityApi idToken before app was loaded',
|
||||
);
|
||||
}
|
||||
return this.idTokenFunc?.();
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
if (!this.hasIdentity) {
|
||||
throw new Error(
|
||||
'Tried to access IdentityApi signOutFunc before app was loaded',
|
||||
);
|
||||
}
|
||||
await this.signOutFunc?.();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// This is indirectly called by the sign-in page to continue into the app.
|
||||
setSignInResult(result: SignInResult) {
|
||||
if (this.hasIdentity) {
|
||||
return;
|
||||
}
|
||||
if (!result.userId) {
|
||||
throw new Error('Invalid sign-in result, userId not set');
|
||||
}
|
||||
if (!result.profile) {
|
||||
throw new Error('Invalid sign-in result, profile not set');
|
||||
}
|
||||
this.hasIdentity = true;
|
||||
this.userId = result.userId;
|
||||
this.profile = result.profile;
|
||||
this.idTokenFunc = result.getIdToken;
|
||||
this.signOutFunc = result.signOut;
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,14 @@ import {
|
||||
AppThemeApi,
|
||||
ConfigApi,
|
||||
featureFlagsApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
BackstagePlugin,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { UserIdentity } from '@backstage/core-components';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
|
||||
import {
|
||||
childDiscoverer,
|
||||
@@ -66,7 +68,7 @@ import { RoutingProvider } from '../routing/RoutingProvider';
|
||||
import { RouteTracker } from '../routing/RouteTracker';
|
||||
import { validateRoutes } from '../routing/validation';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { AppIdentity } from './AppIdentity';
|
||||
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import {
|
||||
AppComponents,
|
||||
AppConfigLoader,
|
||||
@@ -75,7 +77,6 @@ import {
|
||||
AppRouteBinder,
|
||||
BackstageApp,
|
||||
SignInPageProps,
|
||||
SignInResult,
|
||||
} from './types';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
import { defaultConfigLoader } from './defaultConfigLoader';
|
||||
@@ -189,7 +190,7 @@ export class AppManager implements BackstageApp {
|
||||
private readonly defaultApis: Iterable<AnyApiFactory>;
|
||||
private readonly bindRoutes: AppOptions['bindRoutes'];
|
||||
|
||||
private readonly identityApi = new AppIdentity();
|
||||
private readonly appIdentityProxy = new AppIdentityProxy();
|
||||
private readonly apiFactoryRegistry: ApiFactoryRegistry;
|
||||
|
||||
constructor(options: AppOptions) {
|
||||
@@ -344,14 +345,14 @@ export class AppManager implements BackstageApp {
|
||||
component: ComponentType<SignInPageProps>;
|
||||
children: ReactElement;
|
||||
}) => {
|
||||
const [result, setResult] = useState<SignInResult>();
|
||||
const [identityApi, setIdentityApi] = useState<IdentityApi>();
|
||||
|
||||
if (result) {
|
||||
this.identityApi.setSignInResult(result);
|
||||
return children;
|
||||
if (!identityApi) {
|
||||
return <Component onSignInSuccess={setIdentityApi} />;
|
||||
}
|
||||
|
||||
return <Component onResult={setResult} />;
|
||||
this.appIdentityProxy.setTarget(identityApi);
|
||||
return children;
|
||||
};
|
||||
|
||||
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
|
||||
@@ -360,13 +361,7 @@ export class AppManager implements BackstageApp {
|
||||
|
||||
// If the app hasn't configured a sign-in page, we just continue as guest.
|
||||
if (!SignInPageComponent) {
|
||||
this.identityApi.setSignInResult({
|
||||
userId: 'guest',
|
||||
profile: {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
},
|
||||
});
|
||||
this.appIdentityProxy.setTarget(UserIdentity.createGuest());
|
||||
|
||||
return (
|
||||
<RouterComponent>
|
||||
@@ -430,7 +425,7 @@ export class AppManager implements BackstageApp {
|
||||
this.apiFactoryRegistry.register('static', {
|
||||
api: identityApiRef,
|
||||
deps: {},
|
||||
factory: () => this.identityApi,
|
||||
factory: () => this.appIdentityProxy,
|
||||
});
|
||||
|
||||
// It's possible to replace the feature flag API, but since we must have at least
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
SubRouteRef,
|
||||
ExternalRouteRef,
|
||||
PluginOutput,
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
|
||||
@@ -42,6 +43,7 @@ export type BootErrorPageProps = {
|
||||
* The outcome of signing in on the sign-in page.
|
||||
*
|
||||
* @public
|
||||
* @deprecated replaced by passing the {@link @backstage/core-plugin-api#IdentityApi} to the {@link SignInPageProps.onSignInSuccess} instead.
|
||||
*/
|
||||
export type SignInResult = {
|
||||
/**
|
||||
@@ -69,9 +71,9 @@ export type SignInResult = {
|
||||
*/
|
||||
export type SignInPageProps = {
|
||||
/**
|
||||
* Set the sign-in result for the app. This should only be called once.
|
||||
* Set the IdentityApi on successful sign in. This should only be called once.
|
||||
*/
|
||||
onResult(result: SignInResult): void;
|
||||
onSignInSuccess(identityApi: IdentityApi): void;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @backstage/core-components
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a036b65c2f: The `SignInPage` has been updated to use the new `onSignInSuccess` callback that was introduced in the same release. While existing code will usually continue to work, it is technically a breaking change because of the dependency on `SignInProps` from the `@backstage/core-plugin-api`. For more information on this change and instructions on how to migrate existing code, see the [`@backstage/core-app-api` CHANGELOG.md](https://github.com/backstage/backstage/blob/master/packages/core-app-api/CHANGELOG.md).
|
||||
|
||||
Added a new `UserIdentity` class which helps create implementations of the `IdentityApi`. It provides a couple of static factory methods such as the most relevant `create`, and `createGuest` to create an `IdentityApi` for a guest user.
|
||||
|
||||
Also provides a deprecated `fromLegacy` method to create an `IdentityApi` from the now deprecated `SignInResult`. This method will be removed in the future when `SignInResult` is also removed.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9603827bb5: Addressed some peer dependency warnings
|
||||
- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used.
|
||||
- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly
|
||||
- e839500286: Introduce new `LogViewer` component that can be used to display logs. It supports copying, searching, filtering, and displaying text with ANSI color escape codes.
|
||||
- 1357ac30f1: Standardize on `classnames` instead of both that and `clsx`.
|
||||
- e5976071ea: Use ellipsis style for overflowed text in sidebar menu
|
||||
- Updated dependencies
|
||||
- @backstage/core-plugin-api@0.3.0
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
|
||||
import { BackstagePalette } from '@backstage/theme';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
|
||||
import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction';
|
||||
import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button';
|
||||
import { CardHeaderProps } from '@material-ui/core/CardHeader';
|
||||
@@ -21,6 +22,7 @@ import { CSSProperties } from 'react';
|
||||
import { ElementType } from 'react';
|
||||
import { ErrorInfo } from 'react';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { LinearProgressProps } from '@material-ui/core/LinearProgress';
|
||||
import { LinkProps as LinkProps_2 } from '@material-ui/core/Link';
|
||||
import { LinkProps as LinkProps_3 } from 'react-router-dom';
|
||||
@@ -28,6 +30,7 @@ import MaterialBreadcrumbs from '@material-ui/core/Breadcrumbs';
|
||||
import { MaterialTableProps } from '@material-table/core';
|
||||
import { NavLinkProps } from 'react-router-dom';
|
||||
import { Overrides } from '@material-ui/core/styles/overrides';
|
||||
import { ProfileInfo } from '@backstage/core-plugin-api';
|
||||
import { ProfileInfoApi } from '@backstage/core-plugin-api';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
@@ -36,6 +39,7 @@ import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { SessionApi } from '@backstage/core-plugin-api';
|
||||
import { SignInPageProps } from '@backstage/core-plugin-api';
|
||||
import { SignInResult } from '@backstage/core-plugin-api';
|
||||
import { SparklinesLineProps } from 'react-sparklines';
|
||||
import { SparklinesProps } from 'react-sparklines';
|
||||
import { StyledComponentProps } from '@material-ui/core/styles';
|
||||
@@ -606,6 +610,50 @@ export type LinkProps = LinkProps_2 &
|
||||
// @public (undocumented)
|
||||
export type LoginRequestListItemClassKey = 'root';
|
||||
|
||||
// @public
|
||||
export function LogViewer(props: LogViewerProps): JSX.Element;
|
||||
|
||||
// @public
|
||||
export type LogViewerClassKey =
|
||||
| 'root'
|
||||
| 'header'
|
||||
| 'log'
|
||||
| 'line'
|
||||
| 'lineSelected'
|
||||
| 'lineCopyButton'
|
||||
| 'lineNumber'
|
||||
| 'textHighlight'
|
||||
| 'textSelectedHighlight'
|
||||
| 'modifierBold'
|
||||
| 'modifierItalic'
|
||||
| 'modifierUnderline'
|
||||
| 'modifierForegroundBlack'
|
||||
| 'modifierForegroundRed'
|
||||
| 'modifierForegroundGreen'
|
||||
| 'modifierForegroundYellow'
|
||||
| 'modifierForegroundBlue'
|
||||
| 'modifierForegroundMagenta'
|
||||
| 'modifierForegroundCyan'
|
||||
| 'modifierForegroundWhite'
|
||||
| 'modifierForegroundGrey'
|
||||
| 'modifierBackgroundBlack'
|
||||
| 'modifierBackgroundRed'
|
||||
| 'modifierBackgroundGreen'
|
||||
| 'modifierBackgroundYellow'
|
||||
| 'modifierBackgroundBlue'
|
||||
| 'modifierBackgroundMagenta'
|
||||
| 'modifierBackgroundCyan'
|
||||
| 'modifierBackgroundWhite'
|
||||
| 'modifierBackgroundGrey';
|
||||
|
||||
// @public
|
||||
export interface LogViewerProps {
|
||||
classes?: {
|
||||
root?: string;
|
||||
};
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "MarkdownContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
@@ -2014,10 +2062,7 @@ export const SidebarSpacer: React_2.ComponentType<
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const SidebarSubmenu: ({
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<SidebarSubmenuProps>) => JSX.Element;
|
||||
export const SidebarSubmenu: (props: SidebarSubmenuProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const SidebarSubmenuItem: (
|
||||
@@ -2349,6 +2394,33 @@ export function useQueryParamState<T>(
|
||||
// @public (undocumented)
|
||||
export function UserIcon(props: IconComponentProps): JSX.Element;
|
||||
|
||||
// @public
|
||||
export class UserIdentity implements IdentityApi {
|
||||
static create(options: {
|
||||
identity: BackstageUserIdentity;
|
||||
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
|
||||
profile?: ProfileInfo;
|
||||
}): IdentityApi;
|
||||
static createGuest(): IdentityApi;
|
||||
static fromLegacy(result: SignInResult): IdentityApi;
|
||||
// (undocumented)
|
||||
getBackstageIdentity(): Promise<BackstageUserIdentity>;
|
||||
// (undocumented)
|
||||
getCredentials(): Promise<{
|
||||
token?: string | undefined;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getIdToken(): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
getProfile(): ProfileInfo;
|
||||
// (undocumented)
|
||||
getProfileInfo(): Promise<ProfileInfo>;
|
||||
// (undocumented)
|
||||
getUserId(): string;
|
||||
// (undocumented)
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "useSupportConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/core-components",
|
||||
"description": "Core components used by Backstage plugins and apps",
|
||||
"version": "0.7.6",
|
||||
"version": "0.8.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -30,18 +30,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/core-plugin-api": "^0.2.2",
|
||||
"@backstage/core-plugin-api": "^0.3.0",
|
||||
"@backstage/errors": "^0.1.5",
|
||||
"@backstage/theme": "^0.2.14",
|
||||
"@material-table/core": "^3.1.0",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@types/react": "*",
|
||||
"@types/react-sparklines": "^1.7.0",
|
||||
"@types/react-text-truncate": "^0.14.0",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"classnames": "^2.2.6",
|
||||
"clsx": "^1.1.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-shape": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
@@ -53,8 +52,6 @@
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.4",
|
||||
"rc-progress": "^3.0.0",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-hook-form": "^7.12.2",
|
||||
"react-markdown": "^7.0.1",
|
||||
@@ -64,13 +61,20 @@
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
"react-text-truncate": "^0.16.0",
|
||||
"react-use": "^17.2.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"remark-gfm": "^2.0.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
"react-dom": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/core-app-api": "^0.1.24",
|
||||
"@backstage/cli": "^0.10.0",
|
||||
"@backstage/test-utils": "^0.1.23",
|
||||
"@backstage/core-app-api": "^0.2.0",
|
||||
"@backstage/cli": "^0.10.1",
|
||||
"@backstage/test-utils": "^0.1.24",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
@@ -79,12 +83,15 @@
|
||||
"@types/d3-selection": "^3.0.1",
|
||||
"@types/d3-shape": "^3.0.1",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"@types/ansi-regex": "^5.0.0",
|
||||
"@types/dagre": "^0.7.44",
|
||||
"@types/google-protobuf": "^3.7.2",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"@types/react-syntax-highlighter": "^13.5.2",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/zen-observable": "^0.8.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { CopyTextButton } from './CopyTextButton';
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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 { AnsiProcessor } from './AnsiProcessor';
|
||||
|
||||
describe('AnsiProcessor', () => {
|
||||
it('should process a single line', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
modifiers: { foreground: 'red' },
|
||||
},
|
||||
{
|
||||
text: 'baz',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'foobarbaz',
|
||||
lineNumber: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(processor.process(`foo [32mbar[39m: baz`)).toEqual([
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'foo ',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
modifiers: { foreground: 'green' },
|
||||
},
|
||||
{
|
||||
text: ': baz',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'foo bar: baz',
|
||||
lineNumber: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process multiple lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(
|
||||
processor.process(`
|
||||
a\x1b[34mb\x1b[39mc
|
||||
x\x1b[44my\x1b[49mz
|
||||
`),
|
||||
).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'blue' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { background: 'blue' },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'xyz',
|
||||
lineNumber: 3,
|
||||
},
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry state across lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(
|
||||
processor.process(`
|
||||
a\x1b[45mb\x1b[35mc
|
||||
x\x1b[39my\x1b[49mz`),
|
||||
).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'magenta', background: 'magenta' },
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: { foreground: 'magenta', background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'xyz',
|
||||
lineNumber: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry forward state when appending lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
const out1 = processor.process(`
|
||||
a\x1b[36mb\x1b[3mc`);
|
||||
expect(out1).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'cyan' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const out2 = processor.process(`
|
||||
a\x1b[36mb\x1b[3mc
|
||||
x\x1b[39my\x1b[23mz`);
|
||||
expect(out2).toEqual([
|
||||
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'cyan' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
],
|
||||
text: 'abc',
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
chunks: [
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
text: 'xyz',
|
||||
lineNumber: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
// Verifies that we appended rather than reprocessed
|
||||
expect(out1[0]).toBe(out2[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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 ansiRegexMaker from 'ansi-regex';
|
||||
|
||||
const ansiRegex = ansiRegexMaker();
|
||||
const newlineRegex = /\n\r?/g;
|
||||
|
||||
// A mapping of how each escape code changes the modifiers
|
||||
const codeModifiers = Object.fromEntries(
|
||||
Object.entries({
|
||||
1: m => ({ ...m, bold: true }),
|
||||
3: m => ({ ...m, italic: true }),
|
||||
4: m => ({ ...m, underline: true }),
|
||||
22: ({ bold: _, ...m }) => m,
|
||||
23: ({ italic: _, ...m }) => m,
|
||||
24: ({ underline: _, ...m }) => m,
|
||||
30: m => ({ ...m, foreground: 'black' }),
|
||||
31: m => ({ ...m, foreground: 'red' }),
|
||||
32: m => ({ ...m, foreground: 'green' }),
|
||||
33: m => ({ ...m, foreground: 'yellow' }),
|
||||
34: m => ({ ...m, foreground: 'blue' }),
|
||||
35: m => ({ ...m, foreground: 'magenta' }),
|
||||
36: m => ({ ...m, foreground: 'cyan' }),
|
||||
37: m => ({ ...m, foreground: 'white' }),
|
||||
39: ({ foreground: _, ...m }) => m,
|
||||
90: m => ({ ...m, foreground: 'grey' }),
|
||||
40: m => ({ ...m, background: 'black' }),
|
||||
41: m => ({ ...m, background: 'red' }),
|
||||
42: m => ({ ...m, background: 'green' }),
|
||||
43: m => ({ ...m, background: 'yellow' }),
|
||||
44: m => ({ ...m, background: 'blue' }),
|
||||
45: m => ({ ...m, background: 'magenta' }),
|
||||
46: m => ({ ...m, background: 'cyan' }),
|
||||
47: m => ({ ...m, background: 'white' }),
|
||||
49: ({ background: _, ...m }) => m,
|
||||
} as Record<string, (m: ChunkModifiers) => ChunkModifiers>).map(
|
||||
([code, modifier]) => [`\x1b[${code}m`, modifier],
|
||||
),
|
||||
);
|
||||
|
||||
export type AnsiColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'magenta'
|
||||
| 'cyan'
|
||||
| 'white'
|
||||
| 'grey';
|
||||
|
||||
export interface ChunkModifiers {
|
||||
foreground?: AnsiColor;
|
||||
background?: AnsiColor;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
export interface AnsiChunk {
|
||||
text: string;
|
||||
modifiers: ChunkModifiers;
|
||||
}
|
||||
|
||||
export class AnsiLine {
|
||||
text: string;
|
||||
|
||||
constructor(
|
||||
readonly lineNumber: number = 1,
|
||||
readonly chunks: AnsiChunk[] = [],
|
||||
) {
|
||||
this.text = chunks
|
||||
.map(c => c.text)
|
||||
.join('')
|
||||
.toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
lastChunk(): AnsiChunk | undefined {
|
||||
return this.chunks[this.chunks.length - 1];
|
||||
}
|
||||
|
||||
replaceLastChunk(newChunks?: AnsiChunk[]) {
|
||||
if (newChunks) {
|
||||
this.chunks.splice(this.chunks.length - 1, 1, ...newChunks);
|
||||
this.text = this.chunks
|
||||
.map(c => c.text)
|
||||
.join('')
|
||||
.toLocaleLowerCase('en-US');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AnsiProcessor {
|
||||
private text: string = '';
|
||||
private lines: AnsiLine[] = [];
|
||||
|
||||
/**
|
||||
* Processes a chunk of text while keeping internal state that optimizes
|
||||
* subsequent processing that appends to the text.
|
||||
*/
|
||||
process(text: string): AnsiLine[] {
|
||||
if (this.text === text) {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
if (text.startsWith(this.text)) {
|
||||
const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0;
|
||||
const lastLine = this.lines[lastLineIndex] ?? new AnsiLine();
|
||||
const lastChunk = lastLine.lastChunk();
|
||||
|
||||
const newLines = this.processLines(
|
||||
(lastChunk?.text ?? '') + text.slice(this.text.length),
|
||||
lastChunk?.modifiers,
|
||||
lastLine?.lineNumber,
|
||||
);
|
||||
lastLine.replaceLastChunk(newLines[0]?.chunks);
|
||||
|
||||
this.lines[lastLineIndex] = lastLine;
|
||||
this.lines.push(...newLines.slice(1));
|
||||
} else {
|
||||
this.lines = this.processLines(text);
|
||||
}
|
||||
this.text = text;
|
||||
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
// Split a chunk of text up into lines and process each line individually
|
||||
private processLines = (
|
||||
text: string,
|
||||
modifiers: ChunkModifiers = {},
|
||||
startingLineNumber: number = 1,
|
||||
): AnsiLine[] => {
|
||||
const lines: AnsiLine[] = [];
|
||||
|
||||
let currentModifiers = modifiers;
|
||||
let currentLineNumber = startingLineNumber;
|
||||
|
||||
let prevIndex = 0;
|
||||
newlineRegex.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = newlineRegex.exec(text);
|
||||
if (!match) {
|
||||
const chunks = this.processText(
|
||||
text.slice(prevIndex),
|
||||
currentModifiers,
|
||||
);
|
||||
lines.push(new AnsiLine(currentLineNumber, chunks));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const line = text.slice(prevIndex, match.index);
|
||||
prevIndex = match.index + match[0].length;
|
||||
|
||||
const chunks = this.processText(line, currentModifiers);
|
||||
lines.push(new AnsiLine(currentLineNumber, chunks));
|
||||
|
||||
// Modifiers that are active in the last chunk are carried over to the next line
|
||||
currentModifiers =
|
||||
chunks[chunks.length - 1].modifiers ?? currentModifiers;
|
||||
currentLineNumber += 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Processing of a one individual text chunk
|
||||
private processText = (
|
||||
fullText: string,
|
||||
modifiers: ChunkModifiers,
|
||||
): AnsiChunk[] => {
|
||||
const chunks: AnsiChunk[] = [];
|
||||
|
||||
let currentModifiers = modifiers;
|
||||
|
||||
let prevIndex = 0;
|
||||
ansiRegex.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = ansiRegex.exec(fullText);
|
||||
if (!match) {
|
||||
chunks.push({
|
||||
text: fullText.slice(prevIndex),
|
||||
modifiers: currentModifiers,
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const text = fullText.slice(prevIndex, match.index);
|
||||
chunks.push({ text, modifiers: currentModifiers });
|
||||
|
||||
// For every escape code that we encounter we keep track of where the
|
||||
// next chunk of text starts, and what modifiers it has
|
||||
prevIndex = match.index + match[0].length;
|
||||
currentModifiers = this.processCode(match[0], currentModifiers);
|
||||
}
|
||||
};
|
||||
|
||||
private processCode = (
|
||||
code: string,
|
||||
modifiers: ChunkModifiers,
|
||||
): ChunkModifiers => {
|
||||
return codeModifiers[code]?.(modifiers) ?? modifiers;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* 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 { AnsiLine, ChunkModifiers } from './AnsiProcessor';
|
||||
import {
|
||||
calculateHighlightedChunks,
|
||||
findSearchResults,
|
||||
getModifierClasses,
|
||||
} from './LogLine';
|
||||
|
||||
describe('getModifierClasses', () => {
|
||||
const classes = {
|
||||
modifierBold: 'bold',
|
||||
modifierItalic: 'italic',
|
||||
modifierUnderline: 'underline',
|
||||
modifierForegroundBlack: 'black',
|
||||
modifierForegroundRed: 'red',
|
||||
modifierForegroundGreen: 'green',
|
||||
modifierForegroundYellow: 'yellow',
|
||||
modifierForegroundBlue: 'blue',
|
||||
modifierForegroundMagenta: 'magenta',
|
||||
modifierForegroundCyan: 'cyan',
|
||||
modifierForegroundWhite: 'white',
|
||||
modifierForegroundGrey: 'grey',
|
||||
modifierBackgroundBlack: 'bg-black',
|
||||
modifierBackgroundRed: 'bg-red',
|
||||
modifierBackgroundGreen: 'bg-green',
|
||||
modifierBackgroundYellow: 'bg-yellow',
|
||||
modifierBackgroundBlue: 'bg-blue',
|
||||
modifierBackgroundMagenta: 'bg-magenta',
|
||||
modifierBackgroundCyan: 'bg-cyan',
|
||||
modifierBackgroundWhite: 'bg-white',
|
||||
modifierBackgroundGrey: 'bg-grey',
|
||||
};
|
||||
const curried = (modifiers: ChunkModifiers) =>
|
||||
getModifierClasses(
|
||||
classes as Parameters<typeof getModifierClasses>[0],
|
||||
modifiers,
|
||||
);
|
||||
|
||||
it('should transform modifiers to classes', () => {
|
||||
expect(curried({})).toEqual(undefined);
|
||||
expect(curried({ bold: true })).toEqual('bold');
|
||||
expect(curried({ italic: true })).toEqual('italic');
|
||||
expect(curried({ underline: true })).toEqual('underline');
|
||||
expect(curried({ foreground: 'black' })).toEqual('black');
|
||||
expect(curried({ background: 'black' })).toEqual('bg-black');
|
||||
expect(
|
||||
curried({
|
||||
bold: true,
|
||||
italic: true,
|
||||
underline: true,
|
||||
foreground: 'red',
|
||||
background: 'red',
|
||||
}),
|
||||
).toEqual('bold italic underline red bg-red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSearchResults', () => {
|
||||
it('should not return results if there is no match', () => {
|
||||
expect(findSearchResults('Foo', 'Bar')).toEqual(undefined);
|
||||
expect(findSearchResults('Foo Bar', 'oof')).toEqual(undefined);
|
||||
expect(findSearchResults('Foo Bar', '')).toEqual(undefined);
|
||||
expect(findSearchResults('', '')).toEqual(undefined);
|
||||
expect(findSearchResults('', 'Foo')).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should find result indices', () => {
|
||||
expect(findSearchResults('Foo', 'Foo')).toEqual([{ start: 0, end: 3 }]);
|
||||
expect(findSearchResults('Foo', 'o')).toEqual([
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 3 },
|
||||
]);
|
||||
expect(findSearchResults('FooBarBaz', 'Bar')).toEqual([
|
||||
{ start: 3, end: 6 },
|
||||
]);
|
||||
expect(findSearchResults('Foo Bar Baz', ' ')).toEqual([
|
||||
{ start: 3, end: 4 },
|
||||
{ start: 7, end: 8 },
|
||||
]);
|
||||
expect(findSearchResults('FooBarBazBarFoo', 'Bar')).toEqual([
|
||||
{ start: 3, end: 6 },
|
||||
{ start: 9, end: 12 },
|
||||
]);
|
||||
expect(findSearchResults('FooBarBazBarFoo', 'Foo')).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 12, end: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not overlap search results', () => {
|
||||
expect(findSearchResults('aaa', 'aa')).toEqual([{ start: 0, end: 2 }]);
|
||||
expect(findSearchResults('aaaa', 'aa')).toEqual([
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 4 },
|
||||
]);
|
||||
expect(findSearchResults('aaaaa', 'aa')).toEqual([
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateHighlightedChunks', () => {
|
||||
it('should pass through chunks if there are no results', () => {
|
||||
const chunks = [{ text: 'Foo', modifiers: {} }];
|
||||
const line = new AnsiLine(0, chunks);
|
||||
expect(calculateHighlightedChunks(line, 'bar')).toBe(chunks);
|
||||
});
|
||||
|
||||
it('should highlight one result from plain text', () => {
|
||||
const line = new AnsiLine(0, [{ text: 'FooBarBaz', modifiers: {} }]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBaz',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'bar')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Bar',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'baz')).toEqual([
|
||||
{
|
||||
text: 'FooBar',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight multiple results from plain text', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'FooBarBazBazBarFoo', modifiers: {} },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBazBazBar',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'bar')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Bar',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BazBaz',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Bar',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'baz')).toEqual([
|
||||
{
|
||||
text: 'FooBar',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'Baz',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
{
|
||||
text: 'BarFoo',
|
||||
modifiers: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should forward modifiers to result', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'FooBarBazBazBarFoo', modifiers: { bold: true } },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBazBazBar',
|
||||
modifiers: { bold: true },
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight full chunks', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'Foo', modifiers: { bold: true } },
|
||||
{ text: 'BarBaz', modifiers: { bold: true } },
|
||||
{ text: 'BazBar', modifiers: { italic: true } },
|
||||
{ text: 'Foo', modifiers: { italic: true } },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'BarBaz',
|
||||
modifiers: { bold: true },
|
||||
},
|
||||
{
|
||||
text: 'BazBar',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { italic: true },
|
||||
highlight: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight partial chunks', () => {
|
||||
const line = new AnsiLine(0, [
|
||||
{ text: 'Fo', modifiers: { bold: true } },
|
||||
{ text: 'oFooFo', modifiers: {} },
|
||||
{ text: 'oBarBaz', modifiers: { italic: true } },
|
||||
{ text: 'Foo', modifiers: { foreground: 'blue' } },
|
||||
{ text: 'FooFoo', modifiers: { italic: true } },
|
||||
{ text: 'F', modifiers: { bold: true } },
|
||||
{ text: 'o', modifiers: {} },
|
||||
{ text: 'o', modifiers: { bold: true } },
|
||||
]);
|
||||
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
|
||||
{
|
||||
text: 'Fo',
|
||||
modifiers: { bold: true },
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: {},
|
||||
highlight: 0,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: {},
|
||||
highlight: 1,
|
||||
},
|
||||
{
|
||||
text: 'Fo',
|
||||
modifiers: {},
|
||||
highlight: 2,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: { italic: true },
|
||||
highlight: 2,
|
||||
},
|
||||
{
|
||||
text: 'BarBaz',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { foreground: 'blue' },
|
||||
highlight: 3,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { italic: true },
|
||||
highlight: 4,
|
||||
},
|
||||
{
|
||||
text: 'Foo',
|
||||
modifiers: { italic: true },
|
||||
highlight: 5,
|
||||
},
|
||||
{
|
||||
text: 'F',
|
||||
modifiers: { bold: true },
|
||||
highlight: 6,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: {},
|
||||
highlight: 6,
|
||||
},
|
||||
{
|
||||
text: 'o',
|
||||
modifiers: { bold: true },
|
||||
highlight: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor';
|
||||
import startCase from 'lodash/startCase';
|
||||
import classnames from 'classnames';
|
||||
import { useStyles } from './styles';
|
||||
|
||||
export function getModifierClasses(
|
||||
classes: ReturnType<typeof useStyles>,
|
||||
modifiers: ChunkModifiers,
|
||||
) {
|
||||
const classNames = new Array<string>();
|
||||
if (modifiers.bold) {
|
||||
classNames.push(classes.modifierBold);
|
||||
}
|
||||
if (modifiers.italic) {
|
||||
classNames.push(classes.modifierItalic);
|
||||
}
|
||||
if (modifiers.underline) {
|
||||
classNames.push(classes.modifierUnderline);
|
||||
}
|
||||
if (modifiers.foreground) {
|
||||
const key = `modifierForeground${startCase(
|
||||
modifiers.foreground,
|
||||
)}` as keyof typeof classes;
|
||||
classNames.push(classes[key]);
|
||||
}
|
||||
if (modifiers.background) {
|
||||
const key = `modifierBackground${startCase(
|
||||
modifiers.background,
|
||||
)}` as keyof typeof classes;
|
||||
classNames.push(classes[key]);
|
||||
}
|
||||
return classNames.length > 0 ? classNames.join(' ') : undefined;
|
||||
}
|
||||
|
||||
export function findSearchResults(text: string, searchText: string) {
|
||||
if (!searchText || !text.includes(searchText)) {
|
||||
return undefined;
|
||||
}
|
||||
const searchResults = new Array<{ start: number; end: number }>();
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const start = text.indexOf(searchText, offset);
|
||||
if (start === -1) {
|
||||
break;
|
||||
}
|
||||
const end = start + searchText.length;
|
||||
searchResults.push({ start, end });
|
||||
offset = end;
|
||||
}
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
export interface HighlightAnsiChunk extends AnsiChunk {
|
||||
highlight?: number;
|
||||
}
|
||||
|
||||
export function calculateHighlightedChunks(
|
||||
line: AnsiLine,
|
||||
searchText: string,
|
||||
): HighlightAnsiChunk[] {
|
||||
const results = findSearchResults(line.text, searchText);
|
||||
if (!results) {
|
||||
return line.chunks;
|
||||
}
|
||||
|
||||
const chunks = new Array<HighlightAnsiChunk>();
|
||||
|
||||
let lineOffset = 0;
|
||||
let resultIndex = 0;
|
||||
let result = results[resultIndex];
|
||||
for (const chunk of line.chunks) {
|
||||
const { text, modifiers } = chunk;
|
||||
if (!result || lineOffset + text.length < result.start) {
|
||||
chunks.push(chunk);
|
||||
lineOffset += text.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let localOffset = 0;
|
||||
while (result) {
|
||||
const localStart = Math.max(result.start - lineOffset, 0);
|
||||
if (localStart > text.length) {
|
||||
break; // The next result is not in this chunk
|
||||
}
|
||||
|
||||
const localEnd = Math.min(result.end - lineOffset, text.length);
|
||||
|
||||
const hasTextBeforeResult = localStart > localOffset;
|
||||
if (hasTextBeforeResult) {
|
||||
chunks.push({ text: text.slice(localOffset, localStart), modifiers });
|
||||
}
|
||||
const hasResultText = localEnd > localStart;
|
||||
if (hasResultText) {
|
||||
chunks.push({
|
||||
modifiers,
|
||||
highlight: resultIndex,
|
||||
text: text.slice(localStart, localEnd),
|
||||
});
|
||||
}
|
||||
|
||||
localOffset = localEnd;
|
||||
|
||||
const foundCompleteResult = result.end - lineOffset === localEnd;
|
||||
if (foundCompleteResult) {
|
||||
resultIndex += 1;
|
||||
result = results[resultIndex];
|
||||
} else {
|
||||
break; // The rest of the result is in the following chunks
|
||||
}
|
||||
}
|
||||
|
||||
const hasTextAfterResult = localOffset < text.length;
|
||||
if (hasTextAfterResult) {
|
||||
chunks.push({ text: text.slice(localOffset), modifiers });
|
||||
}
|
||||
|
||||
lineOffset += text.length;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export interface LogLineProps {
|
||||
line: AnsiLine;
|
||||
classes: ReturnType<typeof useStyles>;
|
||||
searchText: string;
|
||||
highlightResultIndex?: number;
|
||||
}
|
||||
|
||||
export function LogLine({
|
||||
line,
|
||||
classes,
|
||||
searchText,
|
||||
highlightResultIndex,
|
||||
}: LogLineProps) {
|
||||
const chunks = useMemo(
|
||||
() => calculateHighlightedChunks(line, searchText),
|
||||
[line, searchText],
|
||||
);
|
||||
|
||||
const elements = useMemo(
|
||||
() =>
|
||||
chunks.map(({ text, modifiers, highlight }, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={classnames(
|
||||
getModifierClasses(classes, modifiers),
|
||||
highlight !== undefined &&
|
||||
(highlight === highlightResultIndex
|
||||
? classes.textSelectedHighlight
|
||||
: classes.textHighlight),
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)),
|
||||
[chunks, highlightResultIndex, classes],
|
||||
);
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { LogViewer } from './LogViewer';
|
||||
|
||||
export default {
|
||||
title: 'Data Display/LogViewer',
|
||||
component: LogViewer,
|
||||
decorators: [(Story: ComponentType<{}>) => wrapInTestApp(<Story />)],
|
||||
};
|
||||
|
||||
const exampleLog = `Starting up task with 3 steps
|
||||
Beginning step Fetch Skeleton + Template
|
||||
[32minfo[39m: Fetching template content from remote URL {"timestamp":"2021-12-03T15:47:11.625Z"}
|
||||
[32minfo[39m: Listing files and directories in template {"timestamp":"2021-12-03T15:47:12.797Z"}
|
||||
[32minfo[39m: Processing 33 template files/directories with input values {"component_id":"srnthsrthntrhsn","description":"rnthsrtnhssrthnrsthn","destination":{"host":"github.com","owner":"rtshnsrtmhrstmh","repo":"srtmhsrtmhrsthms"},"owner":"rstnhrstnhsrthn","timestamp":"2021-12-03T15:47:12.801Z"}
|
||||
[32minfo[39m: Writing file .editorconfig to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.816Z"}
|
||||
[32minfo[39m: Writing file .eslintignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.818Z"}
|
||||
[32minfo[39m: Writing file .eslintrc.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.820Z"}
|
||||
[32minfo[39m: Writing directory .github/ to template output path. {"timestamp":"2021-12-03T15:47:12.823Z"}
|
||||
[32minfo[39m: Writing file .gitignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.824Z"}
|
||||
[32minfo[39m: Writing file README.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.827Z"}
|
||||
[32minfo[39m: Writing file babel.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.829Z"}
|
||||
[32minfo[39m: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.831Z"}
|
||||
[32minfo[39m: Writing directory docs/ to template output path. {"timestamp":"2021-12-03T15:47:12.834Z"}
|
||||
[32minfo[39m: Writing file jest.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.836Z"}
|
||||
[32minfo[39m: Writing file mkdocs.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.838Z"}
|
||||
[32minfo[39m: Writing file next-env.d.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.841Z"}
|
||||
[32minfo[39m: Writing file next.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.844Z"}
|
||||
[32minfo[39m: Writing file package.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.845Z"}
|
||||
[32minfo[39m: Writing file prettier.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.848Z"}
|
||||
[32minfo[39m: Writing directory public/ to template output path. {"timestamp":"2021-12-03T15:47:12.849Z"}
|
||||
[32minfo[39m: Writing directory src/ to template output path. {"timestamp":"2021-12-03T15:47:12.850Z"}
|
||||
[32minfo[39m: Writing file tsconfig.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.851Z"}
|
||||
[32minfo[39m: Writing directory .github/workflows/ to template output path. {"timestamp":"2021-12-03T15:47:12.853Z"}
|
||||
[32minfo[39m: Writing file docs/index.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.854Z"}
|
||||
[32minfo[39m: Writing directory public/static/ to template output path. {"timestamp":"2021-12-03T15:47:12.857Z"}
|
||||
[32minfo[39m: Writing directory src/__tests__/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"}
|
||||
[32minfo[39m: Writing directory src/components/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"}
|
||||
[32minfo[39m: Writing directory src/pages/ to template output path. {"timestamp":"2021-12-03T15:47:12.859Z"}
|
||||
[32minfo[39m: Copying file/directory .github/workflows/build.yml without processing. {"timestamp":"2021-12-03T15:47:12.859Z"}
|
||||
[32minfo[39m: Writing file .github/workflows/build.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.860Z"}
|
||||
[32minfo[39m: Writing file public/static/fonts.css to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.861Z"}
|
||||
[32minfo[39m: Writing file src/components/Header.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.863Z"}
|
||||
[32minfo[39m: Writing file src/__tests__/index.test.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.865Z"}
|
||||
[32minfo[39m: Writing file src/pages/_app.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.868Z"}
|
||||
[32minfo[39m: Writing file src/pages/_document.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.871Z"}
|
||||
[32minfo[39m: Writing directory src/pages/api/ to template output path. {"timestamp":"2021-12-03T15:47:12.873Z"}
|
||||
[32minfo[39m: Writing file src/pages/index.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.874Z"}
|
||||
[32minfo[39m: Writing file src/pages/api/ping.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.877Z"}
|
||||
[32minfo[39m: Template result written to /var/folders/k6/9s7hd6w17115xlgwnsp0wsbr0000gn/T/5c9f8584-fded-4741-b6ef-46d94ff2cbdb {"timestamp":"2021-12-03T15:47:12.878Z"}
|
||||
Finished step Fetch Skeleton + Template
|
||||
Beginning step Publish
|
||||
HttpError: Not Found
|
||||
at /Users/patriko/dev/backstage/node_modules/@octokit/request/dist-node/index.js:86:21
|
||||
at runMicrotasks (<anonymous>)
|
||||
at processTicksAndRejections (internal/process/task_queues.js:95:5)
|
||||
at async Object.handler (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts:156:20)
|
||||
at async HandlebarsWorkflowRunner.execute (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts:254:11)
|
||||
at async TaskWorker.runOneTask (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:110:13)
|
||||
at async eval (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:100:9)
|
||||
Run completed with status: failed`;
|
||||
|
||||
export const ExampleLogViewer = () => (
|
||||
<div style={{ height: 240 }}>
|
||||
<LogViewer text={exampleLog} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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, { lazy, Suspense } from 'react';
|
||||
import { useApp } from '@backstage/core-plugin-api';
|
||||
|
||||
const RealLogViewer = lazy(() =>
|
||||
import('./RealLogViewer').then(m => ({ default: m.RealLogViewer })),
|
||||
);
|
||||
|
||||
/**
|
||||
* The properties for the LogViewer component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface LogViewerProps {
|
||||
/**
|
||||
* The text of the logs to display.
|
||||
*
|
||||
* The LogViewer component is optimized for appending content at the end of the text.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Styling overrides for classes within the LogViewer component.
|
||||
*/
|
||||
classes?: {
|
||||
root?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays logs in a scrollable text area.
|
||||
*
|
||||
* The LogViewer has support for search and filtering, as well as displaying
|
||||
* text content with ANSI color escape codes.
|
||||
*
|
||||
* Since the LogViewer uses windowing to avoid rendering all contents at once, the
|
||||
* log is sized automatically to fill the available vertical space. This means
|
||||
* it may often be needed to wrap the LogViewer in a container that provides it
|
||||
* with a fixed amount of space.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function LogViewer(props: LogViewerProps) {
|
||||
const { Progress } = useApp().getComponents();
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<RealLogViewer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 IconButton from '@material-ui/core/IconButton';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import { LogViewerSearch } from './useLogViewerSearch';
|
||||
|
||||
export interface LogViewerControlsProps extends LogViewerSearch {}
|
||||
|
||||
export function LogViewerControls(props: LogViewerControlsProps) {
|
||||
const { resultCount, resultIndexStep, toggleShouldFilter } = props;
|
||||
const resultIndex = props.resultIndex ?? 0;
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
toggleShouldFilter();
|
||||
} else {
|
||||
resultIndexStep(event.shiftKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{resultCount !== undefined && (
|
||||
<>
|
||||
<IconButton size="small" onClick={() => resultIndexStep(true)}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<Typography>
|
||||
{Math.min(resultIndex + 1, resultCount)}/{resultCount}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => resultIndexStep()}>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<TextField
|
||||
size="small"
|
||||
variant="standard"
|
||||
placeholder="Search"
|
||||
value={props.searchInput}
|
||||
onKeyPress={handleKeyPress}
|
||||
onChange={e => props.setSearchInput(e.target.value)}
|
||||
/>
|
||||
<IconButton size="small" onClick={toggleShouldFilter}>
|
||||
{props.shouldFilter ? (
|
||||
<FilterListIcon color="primary" />
|
||||
) : (
|
||||
<FilterListIcon color="disabled" />
|
||||
)}
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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, { ReactNode } from 'react';
|
||||
import UserEvent from '@testing-library/user-event';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { RealLogViewer } from './RealLogViewer';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import copyToClipboard from 'copy-to-clipboard';
|
||||
|
||||
// Used by useCopyToClipboard
|
||||
jest.mock('copy-to-clipboard', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
|
||||
jest.mock('react-virtualized-auto-sizer', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) => <>{props.children({ width: 400, height: 200 })}</>,
|
||||
}));
|
||||
|
||||
const testText = `Some Log Line
|
||||
Derp
|
||||
Foo
|
||||
|
||||
Foo Foo
|
||||
Wat`;
|
||||
|
||||
describe('RealLogViewer', () => {
|
||||
it('should render text with search and filtering and copying', async () => {
|
||||
const rendered = await renderInTestApp(<RealLogViewer text={testText} />);
|
||||
expect(rendered.getByText('Derp')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Foo Foo')).toBeInTheDocument();
|
||||
|
||||
UserEvent.tab();
|
||||
UserEvent.keyboard('Foo');
|
||||
|
||||
expect(rendered.getByText('1/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{enter}');
|
||||
expect(rendered.getByText('2/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{enter}');
|
||||
expect(rendered.getByText('3/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{enter}');
|
||||
expect(rendered.getByText('1/3')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{shift}{enter}{/shift}');
|
||||
expect(rendered.getByText('3/3')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.queryByText('Some Log Line')).toBeInTheDocument();
|
||||
UserEvent.keyboard('{meta}{enter}{/meta}');
|
||||
expect(rendered.queryByText('Some Log Line')).not.toBeInTheDocument();
|
||||
UserEvent.keyboard('{meta}{enter}{/meta}');
|
||||
expect(rendered.queryByText('Some Log Line')).toBeInTheDocument();
|
||||
|
||||
// Tab down to line #2 and click
|
||||
UserEvent.tab();
|
||||
UserEvent.tab();
|
||||
UserEvent.tab();
|
||||
UserEvent.click(document.activeElement!);
|
||||
UserEvent.click(rendered.getByTestId('copy-button'));
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('Derp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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, { useEffect, useMemo, useRef } from 'react';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import CopyIcon from '@material-ui/icons/FileCopy';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { AnsiProcessor } from './AnsiProcessor';
|
||||
import { HEADER_SIZE, useStyles } from './styles';
|
||||
import classnames from 'classnames';
|
||||
import { LogLine } from './LogLine';
|
||||
import { LogViewerControls } from './LogViewerControls';
|
||||
import { useLogViewerSearch } from './useLogViewerSearch';
|
||||
import { useLogViewerSelection } from './useLogViewerSelection';
|
||||
|
||||
export interface RealLogViewerProps {
|
||||
text: string;
|
||||
classes?: { root?: string };
|
||||
}
|
||||
|
||||
export function RealLogViewer(props: RealLogViewerProps) {
|
||||
const classes = useStyles({ classes: props.classes });
|
||||
const listRef = useRef<FixedSizeList | null>(null);
|
||||
|
||||
// The processor keeps state that optimizes appending to the text
|
||||
const processor = useMemo(() => new AnsiProcessor(), []);
|
||||
const lines = processor.process(props.text);
|
||||
|
||||
const search = useLogViewerSearch(lines);
|
||||
const selection = useLogViewerSelection(lines);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.resultLine !== undefined && listRef.current) {
|
||||
listRef.current.scrollToItem(search.resultLine - 1, 'center');
|
||||
}
|
||||
}, [search.resultLine]);
|
||||
|
||||
const handleSelectLine = (
|
||||
line: number,
|
||||
event: { shiftKey: boolean; preventDefault: () => void },
|
||||
) => {
|
||||
event.preventDefault();
|
||||
selection.setSelection(line, event.shiftKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<div style={{ width, height }} className={classes.root}>
|
||||
<div className={classes.header}>
|
||||
<LogViewerControls {...search} />
|
||||
</div>
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
className={classes.log}
|
||||
height={height - HEADER_SIZE}
|
||||
width={width}
|
||||
itemData={search.lines}
|
||||
itemSize={20}
|
||||
itemCount={search.lines.length}
|
||||
>
|
||||
{({ index, style, data }) => {
|
||||
const line = data[index];
|
||||
const { lineNumber } = line;
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className={classnames(classes.line, {
|
||||
[classes.lineSelected]: selection.isSelected(lineNumber),
|
||||
})}
|
||||
>
|
||||
{selection.shouldShowButton(lineNumber) && (
|
||||
<IconButton
|
||||
data-testid="copy-button"
|
||||
size="small"
|
||||
className={classes.lineCopyButton}
|
||||
onClick={() => selection.copySelection()}
|
||||
>
|
||||
<CopyIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
)}
|
||||
<a
|
||||
role="row"
|
||||
target="_self"
|
||||
href={`#line-${lineNumber}`}
|
||||
className={classes.lineNumber}
|
||||
onClick={event => handleSelectLine(lineNumber, event)}
|
||||
onKeyPress={event => handleSelectLine(lineNumber, event)}
|
||||
>
|
||||
{lineNumber}
|
||||
</a>
|
||||
<LogLine
|
||||
line={line}
|
||||
classes={classes}
|
||||
searchText={search.searchText}
|
||||
highlightResultIndex={
|
||||
search.resultLine === lineNumber
|
||||
? search.resultLineIndex
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { LogViewer } from './LogViewer';
|
||||
export type { LogViewerProps } from './LogViewer';
|
||||
export type { LogViewerClassKey } from './styles';
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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 { alpha, makeStyles } from '@material-ui/core/styles';
|
||||
import * as colors from '@material-ui/core/colors';
|
||||
|
||||
export const HEADER_SIZE = 40;
|
||||
|
||||
/** @public Class keys for overriding LogViewer styles */
|
||||
export type LogViewerClassKey =
|
||||
| 'root'
|
||||
| 'header'
|
||||
| 'log'
|
||||
| 'line'
|
||||
| 'lineSelected'
|
||||
| 'lineCopyButton'
|
||||
| 'lineNumber'
|
||||
| 'textHighlight'
|
||||
| 'textSelectedHighlight'
|
||||
| 'modifierBold'
|
||||
| 'modifierItalic'
|
||||
| 'modifierUnderline'
|
||||
| 'modifierForegroundBlack'
|
||||
| 'modifierForegroundRed'
|
||||
| 'modifierForegroundGreen'
|
||||
| 'modifierForegroundYellow'
|
||||
| 'modifierForegroundBlue'
|
||||
| 'modifierForegroundMagenta'
|
||||
| 'modifierForegroundCyan'
|
||||
| 'modifierForegroundWhite'
|
||||
| 'modifierForegroundGrey'
|
||||
| 'modifierBackgroundBlack'
|
||||
| 'modifierBackgroundRed'
|
||||
| 'modifierBackgroundGreen'
|
||||
| 'modifierBackgroundYellow'
|
||||
| 'modifierBackgroundBlue'
|
||||
| 'modifierBackgroundMagenta'
|
||||
| 'modifierBackgroundCyan'
|
||||
| 'modifierBackgroundWhite'
|
||||
| 'modifierBackgroundGrey';
|
||||
|
||||
export const useStyles = makeStyles(
|
||||
theme => ({
|
||||
root: {
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_SIZE,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
log: {
|
||||
fontFamily: '"Monaco", monospace',
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
},
|
||||
line: {
|
||||
position: 'relative',
|
||||
whiteSpace: 'pre',
|
||||
|
||||
'&:hover': {
|
||||
background: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
lineSelected: {
|
||||
background: theme.palette.action.selected,
|
||||
|
||||
'&:hover': {
|
||||
background: theme.palette.action.selected,
|
||||
},
|
||||
},
|
||||
lineCopyButton: {
|
||||
position: 'absolute',
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
lineNumber: {
|
||||
display: 'inline-block',
|
||||
textAlign: 'end',
|
||||
width: 60,
|
||||
marginRight: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
},
|
||||
textHighlight: {
|
||||
background: alpha(theme.palette.info.main, 0.15),
|
||||
},
|
||||
textSelectedHighlight: {
|
||||
background: alpha(theme.palette.info.main, 0.4),
|
||||
},
|
||||
modifierBold: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
},
|
||||
modifierItalic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
modifierUnderline: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
modifierForegroundBlack: {
|
||||
color: colors.common.black,
|
||||
},
|
||||
modifierForegroundRed: {
|
||||
color: colors.red[500],
|
||||
},
|
||||
modifierForegroundGreen: {
|
||||
color: colors.green[500],
|
||||
},
|
||||
modifierForegroundYellow: {
|
||||
color: colors.yellow[500],
|
||||
},
|
||||
modifierForegroundBlue: {
|
||||
color: colors.blue[500],
|
||||
},
|
||||
modifierForegroundMagenta: {
|
||||
color: colors.purple[500],
|
||||
},
|
||||
modifierForegroundCyan: {
|
||||
color: colors.cyan[500],
|
||||
},
|
||||
modifierForegroundWhite: {
|
||||
color: colors.common.white,
|
||||
},
|
||||
modifierForegroundGrey: {
|
||||
color: colors.grey[500],
|
||||
},
|
||||
modifierBackgroundBlack: {
|
||||
background: colors.common.black,
|
||||
},
|
||||
modifierBackgroundRed: {
|
||||
background: colors.red[500],
|
||||
},
|
||||
modifierBackgroundGreen: {
|
||||
background: colors.green[500],
|
||||
},
|
||||
modifierBackgroundYellow: {
|
||||
background: colors.yellow[500],
|
||||
},
|
||||
modifierBackgroundBlue: {
|
||||
background: colors.blue[500],
|
||||
},
|
||||
modifierBackgroundMagenta: {
|
||||
background: colors.purple[500],
|
||||
},
|
||||
modifierBackgroundCyan: {
|
||||
background: colors.cyan[500],
|
||||
},
|
||||
modifierBackgroundWhite: {
|
||||
background: colors.common.white,
|
||||
},
|
||||
modifierBackgroundGrey: {
|
||||
background: colors.grey[500],
|
||||
},
|
||||
}),
|
||||
{ name: 'BackstageLogViewer' },
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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 { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { applySearchFilter, useLogViewerSearch } from './useLogViewerSearch';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
|
||||
const lines = [
|
||||
new AnsiLine(1, [{ text: 'FooBar', modifiers: {} }]),
|
||||
new AnsiLine(2, [{ text: 'Baz', modifiers: {} }]),
|
||||
new AnsiLine(3, [{ text: 'FooBarFoo', modifiers: {} }]),
|
||||
new AnsiLine(4, [{ text: 'Baz', modifiers: {} }]),
|
||||
new AnsiLine(5, [{ text: 'BazFoo', modifiers: {} }]),
|
||||
new AnsiLine(6, [{ text: 'FooFooFoo', modifiers: {} }]),
|
||||
new AnsiLine(7, [{ text: '', modifiers: {} }]),
|
||||
new AnsiLine(8, [{ text: 'Bar', modifiers: {} }]),
|
||||
];
|
||||
|
||||
describe('applySearchFilter', () => {
|
||||
it('should find search results', () => {
|
||||
expect(applySearchFilter(lines, '')).toEqual({
|
||||
lines: lines,
|
||||
results: undefined,
|
||||
});
|
||||
expect(applySearchFilter(lines, 'foo')).toEqual({
|
||||
lines: [lines[0], lines[2], lines[4], lines[5]],
|
||||
results: [
|
||||
{ lineNumber: 1, lineIndex: 0 },
|
||||
{ lineNumber: 3, lineIndex: 0 },
|
||||
{ lineNumber: 3, lineIndex: 1 },
|
||||
{ lineNumber: 5, lineIndex: 0 },
|
||||
{ lineNumber: 6, lineIndex: 0 },
|
||||
{ lineNumber: 6, lineIndex: 1 },
|
||||
{ lineNumber: 6, lineIndex: 2 },
|
||||
],
|
||||
});
|
||||
expect(applySearchFilter(lines, 'bar')).toEqual({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
results: [
|
||||
{ lineNumber: 1, lineIndex: 0 },
|
||||
{ lineNumber: 3, lineIndex: 0 },
|
||||
{ lineNumber: 8, lineIndex: 0 },
|
||||
],
|
||||
});
|
||||
expect(applySearchFilter(lines, 'baz')).toEqual({
|
||||
lines: [lines[1], lines[3], lines[4]],
|
||||
results: [
|
||||
{ lineNumber: 2, lineIndex: 0 },
|
||||
{ lineNumber: 4, lineIndex: 0 },
|
||||
{ lineNumber: 5, lineIndex: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogViewerSearch', () => {
|
||||
it('should provide search state', () => {
|
||||
const rendered = renderHook(() => useLogViewerSearch(lines));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchText: '',
|
||||
shouldFilter: false,
|
||||
resultCount: undefined,
|
||||
resultIndex: 0,
|
||||
resultLine: undefined,
|
||||
resultLineIndex: undefined,
|
||||
});
|
||||
|
||||
rendered.result.current.resultIndexStep();
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchText: '',
|
||||
shouldFilter: false,
|
||||
resultCount: undefined,
|
||||
resultIndex: 0,
|
||||
resultLine: undefined,
|
||||
resultLineIndex: undefined,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.toggleShouldFilter());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchText: '',
|
||||
shouldFilter: true,
|
||||
resultCount: undefined,
|
||||
resultIndex: 0,
|
||||
resultLine: undefined,
|
||||
resultLineIndex: undefined,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.setSearchInput('BAR'));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
searchInput: 'BAR',
|
||||
searchText: 'bar',
|
||||
shouldFilter: true,
|
||||
resultCount: 3,
|
||||
resultIndex: 0,
|
||||
resultLine: 1,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 1,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 2,
|
||||
resultLine: 8,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 0,
|
||||
resultLine: 1,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep(true));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[7]],
|
||||
resultIndex: 2,
|
||||
resultLine: 8,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.setSearchInput('FOO'));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines: [lines[0], lines[2], lines[4], lines[5]],
|
||||
searchInput: 'FOO',
|
||||
searchText: 'foo',
|
||||
shouldFilter: true,
|
||||
resultCount: 7,
|
||||
resultIndex: 2,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 1,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.toggleShouldFilter());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
shouldFilter: false,
|
||||
resultCount: 7,
|
||||
resultIndex: 2,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 1,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
lines,
|
||||
searchInput: 'FOO',
|
||||
searchText: 'foo',
|
||||
shouldFilter: false,
|
||||
resultCount: 7,
|
||||
resultIndex: 3,
|
||||
resultLine: 5,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
resultIndex: 4,
|
||||
resultLine: 6,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
resultIndex: 5,
|
||||
resultLine: 6,
|
||||
resultLineIndex: 1,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep());
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
resultIndex: 6,
|
||||
resultLine: 6,
|
||||
resultLineIndex: 2,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.setSearchInput('BAR'));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
searchText: 'bar',
|
||||
resultCount: 3,
|
||||
resultIndex: 6,
|
||||
resultLine: 8,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
|
||||
act(() => rendered.result.current.resultIndexStep(true));
|
||||
expect(rendered.result.current).toMatchObject({
|
||||
searchText: 'bar',
|
||||
resultCount: 3,
|
||||
resultIndex: 1,
|
||||
resultLine: 3,
|
||||
resultLineIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 { useMemo, useState } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
|
||||
export function applySearchFilter(lines: AnsiLine[], searchText: string) {
|
||||
if (!searchText) {
|
||||
return { lines };
|
||||
}
|
||||
|
||||
const matchingLines = [];
|
||||
const searchResults = [];
|
||||
for (const line of lines) {
|
||||
if (line.text.includes(searchText)) {
|
||||
matchingLines.push(line);
|
||||
|
||||
let offset = 0;
|
||||
let lineResultIndex = 0;
|
||||
for (;;) {
|
||||
const start = line.text.indexOf(searchText, offset);
|
||||
if (start === -1) {
|
||||
break;
|
||||
}
|
||||
searchResults.push({
|
||||
lineNumber: line.lineNumber,
|
||||
lineIndex: lineResultIndex++,
|
||||
});
|
||||
offset = start + searchText.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lines: matchingLines,
|
||||
results: searchResults,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LogViewerSearch {
|
||||
lines: AnsiLine[];
|
||||
|
||||
searchText: string;
|
||||
searchInput: string;
|
||||
setSearchInput: (searchInput: string) => void;
|
||||
|
||||
shouldFilter: boolean;
|
||||
toggleShouldFilter: () => void;
|
||||
|
||||
resultCount: number | undefined;
|
||||
resultIndex: number | undefined;
|
||||
resultIndexStep: (decrement?: boolean) => void;
|
||||
|
||||
resultLine: number | undefined;
|
||||
resultLineIndex: number | undefined;
|
||||
}
|
||||
|
||||
export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const searchText = searchInput.toLocaleLowerCase('en-US');
|
||||
|
||||
const [resultIndex, setResultIndex] = useState<number>(0);
|
||||
|
||||
const [shouldFilter, toggleShouldFilter] = useToggle(false);
|
||||
|
||||
const filter = useMemo(
|
||||
() => applySearchFilter(lines, searchText),
|
||||
[lines, searchText],
|
||||
);
|
||||
|
||||
const searchResult = filter.results
|
||||
? filter.results[Math.min(resultIndex, filter.results.length - 1)]
|
||||
: undefined;
|
||||
const resultCount = filter.results?.length;
|
||||
|
||||
const resultIndexStep = (decrement?: boolean) => {
|
||||
if (decrement) {
|
||||
if (resultCount !== undefined) {
|
||||
const next = Math.min(resultIndex - 1, resultCount - 2);
|
||||
setResultIndex(next < 0 ? resultCount - 1 : next);
|
||||
}
|
||||
} else {
|
||||
if (resultCount !== undefined) {
|
||||
const next = resultIndex + 1;
|
||||
setResultIndex(next >= resultCount ? 0 : next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lines: shouldFilter ? filter.lines : lines,
|
||||
searchText,
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
shouldFilter,
|
||||
toggleShouldFilter,
|
||||
resultCount,
|
||||
resultIndex,
|
||||
resultIndexStep,
|
||||
resultLine: searchResult?.lineNumber,
|
||||
resultLineIndex: searchResult?.lineIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { TestApiProvider, MockErrorApi } from '@backstage/test-utils';
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
import { useLogViewerSelection } from './useLogViewerSelection';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import copyToClipboard from 'copy-to-clipboard';
|
||||
|
||||
// Used by useCopyToClipboard
|
||||
jest.mock('copy-to-clipboard', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const lines = [
|
||||
new AnsiLine(1, [{ text: '1', modifiers: {} }]),
|
||||
new AnsiLine(2, [{ text: '2', modifiers: {} }]),
|
||||
new AnsiLine(3, [{ text: '3', modifiers: {} }]),
|
||||
new AnsiLine(4, [{ text: '4', modifiers: {} }]),
|
||||
new AnsiLine(5, [{ text: '5', modifiers: {} }]),
|
||||
];
|
||||
|
||||
describe('useLogViewerSelection', () => {
|
||||
it('should manage a selection', () => {
|
||||
const rendered = renderHook(() => useLogViewerSelection(lines), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(2, false));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(3, false));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(4)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(1, true));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(4)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
|
||||
|
||||
act(() => rendered.result.current.setSelection(4, true));
|
||||
|
||||
expect(rendered.result.current.isSelected(1)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(2)).toBe(false);
|
||||
expect(rendered.result.current.isSelected(3)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(4)).toBe(true);
|
||||
expect(rendered.result.current.isSelected(5)).toBe(false);
|
||||
|
||||
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
|
||||
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(4)).toBe(true);
|
||||
expect(rendered.result.current.shouldShowButton(5)).toBe(false);
|
||||
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
act(() => rendered.result.current.copySelection());
|
||||
expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4');
|
||||
|
||||
act(() => rendered.result.current.setSelection(2, true));
|
||||
act(() => rendered.result.current.setSelection(4, true));
|
||||
|
||||
act(() => rendered.result.current.copySelection());
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4');
|
||||
|
||||
act(() => rendered.result.current.setSelection(2, false));
|
||||
act(() => rendered.result.current.setSelection(4, false));
|
||||
act(() => rendered.result.current.setSelection(4, false));
|
||||
act(() => rendered.result.current.setSelection(5, true));
|
||||
act(() => rendered.result.current.copySelection());
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { AnsiLine } from './AnsiProcessor';
|
||||
|
||||
export function useLogViewerSelection(lines: AnsiLine[]) {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const [sel, setSelection] = useState<{ start: number; end: number }>();
|
||||
const start = sel ? Math.min(sel.start, sel.end) : undefined;
|
||||
const end = sel ? Math.max(sel.start, sel.end) : undefined;
|
||||
|
||||
const [{ error }, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [error, errorApi]);
|
||||
|
||||
return {
|
||||
shouldShowButton(line: number) {
|
||||
return start === line || end === line;
|
||||
},
|
||||
isSelected(line: number) {
|
||||
if (!sel) {
|
||||
return false;
|
||||
}
|
||||
return start! <= line && line <= end!;
|
||||
},
|
||||
setSelection(line: number, add: boolean) {
|
||||
if (add) {
|
||||
setSelection(s =>
|
||||
s ? { start: s.start, end: line } : { start: line, end: line },
|
||||
);
|
||||
} else {
|
||||
setSelection(s =>
|
||||
s?.start === line && s?.end === line
|
||||
? undefined
|
||||
: { start: line, end: line },
|
||||
);
|
||||
}
|
||||
},
|
||||
copySelection() {
|
||||
if (sel) {
|
||||
const copyText = lines
|
||||
.slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end))
|
||||
.map(l => l.chunks.map(c => c.text).join(''))
|
||||
.join('\n');
|
||||
copyToClipboard(copyText);
|
||||
setSelection(undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import { Progress } from './Progress';
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { RoutedTabs } from './RoutedTabs';
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { TabbedLayout } from './TabbedLayout';
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export * from './HeaderIconLinkRow';
|
||||
export * from './HorizontalScrollGrid';
|
||||
export * from './Lifecycle';
|
||||
export * from './Link';
|
||||
export * from './LogViewer';
|
||||
export * from './MarkdownContent';
|
||||
export * from './OAuthRequestDialog';
|
||||
export * from './OverflowTooltip';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import useMediaQuery from '@material-ui/core/useMediaQuery';
|
||||
import clsx from 'clsx';
|
||||
import classnames from 'classnames';
|
||||
import React, { useState, useContext, PropsWithChildren, useRef } from 'react';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
@@ -163,7 +163,7 @@ const DesktopSidebar = ({
|
||||
onMouseLeave={handleClose}
|
||||
onBlur={handleClose}
|
||||
data-testid="sidebar-root"
|
||||
className={clsx(classes.drawer, {
|
||||
className={classnames(classes.drawer, {
|
||||
[classes.drawerOpen]: isOpen,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import Typography from '@material-ui/core/Typography';
|
||||
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
|
||||
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import clsx from 'clsx';
|
||||
import classnames from 'classnames';
|
||||
import React, {
|
||||
Children,
|
||||
forwardRef,
|
||||
@@ -343,10 +343,11 @@ const SidebarItemWithSubmenu = ({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={isHoveredOn ? handleMouseLeave : handleMouseEnter}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className={classnames(isHoveredOn && classes.highlighted)}
|
||||
>
|
||||
<div
|
||||
data-testid="item-with-submenu"
|
||||
className={clsx(
|
||||
className={classnames(
|
||||
classes.root,
|
||||
isOpen ? classes.open : classes.closed,
|
||||
isActive && classes.selected,
|
||||
@@ -446,7 +447,10 @@ export const WorkaroundNavLink = React.forwardRef<
|
||||
ref={ref}
|
||||
aria-current={ariaCurrent}
|
||||
style={{ ...style, ...(isActive ? activeStyle : undefined) }}
|
||||
className={clsx([className, isActive ? activeClassName : undefined])}
|
||||
className={classnames([
|
||||
className,
|
||||
isActive ? activeClassName : undefined,
|
||||
])}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -474,7 +478,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
variant="dot"
|
||||
overlap="circular"
|
||||
invisible={!hasNotifications}
|
||||
className={clsx({ [classes.closedItemIcon]: !isOpen })}
|
||||
className={classnames({ [classes.closedItemIcon]: !isOpen })}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Badge>
|
||||
@@ -500,7 +504,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
|
||||
const childProps = {
|
||||
onClick,
|
||||
className: clsx(
|
||||
className: classnames(
|
||||
className,
|
||||
classes.root,
|
||||
isOpen ? classes.open : classes.closed,
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import clsx from 'clsx';
|
||||
import React, { PropsWithChildren, ReactNode, useContext } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import React, { ReactNode, useContext } from 'react';
|
||||
import {
|
||||
SidebarItemWithSubmenuContext,
|
||||
sidebarConfig,
|
||||
@@ -97,28 +97,24 @@ export type SidebarSubmenuProps = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SidebarSubmenu = ({
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<SidebarSubmenuProps>) => {
|
||||
export const SidebarSubmenu = (props: SidebarSubmenuProps) => {
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
const left = isOpen
|
||||
? sidebarConfig.drawerWidthOpen
|
||||
: sidebarConfig.drawerWidthClosed;
|
||||
const props = { left };
|
||||
const classes = useStyles(props)();
|
||||
const classes = useStyles({ left })();
|
||||
|
||||
const { isHoveredOn } = useContext(SidebarItemWithSubmenuContext);
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.drawer, {
|
||||
className={classnames(classes.drawer, {
|
||||
[classes.drawerOpen]: isHoveredOn,
|
||||
})}
|
||||
>
|
||||
<Typography variant="h5" className={classes.title}>
|
||||
{title}
|
||||
{props.title}
|
||||
</Typography>
|
||||
{children}
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import { makeStyles } from '@material-ui/core/styles';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Link from '@material-ui/core/Link';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import clsx from 'clsx';
|
||||
import classnames from 'classnames';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
|
||||
import ArrowDropUpIcon from '@material-ui/icons/ArrowDropUp';
|
||||
@@ -149,7 +149,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
<button
|
||||
onClick={handleClickDropdown}
|
||||
onTouchStart={e => e.stopPropagation()}
|
||||
className={clsx(
|
||||
className={classnames(
|
||||
classes.item,
|
||||
isActive ? classes.selected : undefined,
|
||||
)}
|
||||
@@ -193,7 +193,10 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
component={NavLink}
|
||||
to={to}
|
||||
underline="none"
|
||||
className={clsx(classes.item, isActive ? classes.selected : undefined)}
|
||||
className={classnames(
|
||||
classes.item,
|
||||
isActive ? classes.selected : undefined,
|
||||
)}
|
||||
onClick={closeSubmenu}
|
||||
onTouchStart={e => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
IdentityApi,
|
||||
ProfileInfo,
|
||||
BackstageUserIdentity,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export class GuestUserIdentity implements IdentityApi {
|
||||
getUserId(): string {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getProfile(): ProfileInfo {
|
||||
return {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
};
|
||||
}
|
||||
|
||||
async getProfileInfo(): Promise<ProfileInfo> {
|
||||
return {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
};
|
||||
}
|
||||
|
||||
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
|
||||
const userEntityRef = `user:default/guest`;
|
||||
return {
|
||||
type: 'user',
|
||||
userEntityRef,
|
||||
ownershipEntityRefs: [userEntityRef],
|
||||
};
|
||||
}
|
||||
|
||||
async getCredentials(): Promise<{ token?: string | undefined }> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstageUserIdentity,
|
||||
IdentityApi,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export class IdentityApiSignOutProxy implements IdentityApi {
|
||||
private constructor(
|
||||
private readonly config: {
|
||||
identityApi: IdentityApi;
|
||||
signOut: IdentityApi['signOut'];
|
||||
},
|
||||
) {}
|
||||
|
||||
static from(config: {
|
||||
identityApi: IdentityApi;
|
||||
signOut: IdentityApi['signOut'];
|
||||
}): IdentityApi {
|
||||
return new IdentityApiSignOutProxy(config);
|
||||
}
|
||||
|
||||
getUserId(): string {
|
||||
return this.config.identityApi.getUserId();
|
||||
}
|
||||
|
||||
getIdToken(): Promise<string | undefined> {
|
||||
return this.config.identityApi.getIdToken();
|
||||
}
|
||||
|
||||
getProfile(): ProfileInfo {
|
||||
return this.config.identityApi.getProfile();
|
||||
}
|
||||
|
||||
getProfileInfo(): Promise<ProfileInfo> {
|
||||
return this.config.identityApi.getProfileInfo();
|
||||
}
|
||||
|
||||
getBackstageIdentity(): Promise<BackstageUserIdentity> {
|
||||
return this.config.identityApi.getBackstageIdentity();
|
||||
}
|
||||
|
||||
getCredentials(): Promise<{ token?: string | undefined }> {
|
||||
return this.config.identityApi.getCredentials();
|
||||
}
|
||||
|
||||
signOut(): Promise<void> {
|
||||
return this.config.signOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 {
|
||||
IdentityApi,
|
||||
ProfileInfo,
|
||||
BackstageUserIdentity,
|
||||
SignInResult,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
function parseJwtPayload(token: string) {
|
||||
const [_header, payload, _signature] = token.split('.');
|
||||
return JSON.parse(atob(payload));
|
||||
}
|
||||
|
||||
export class LegacyUserIdentity implements IdentityApi {
|
||||
private constructor(private readonly result: SignInResult) {}
|
||||
|
||||
getUserId(): string {
|
||||
return this.result.userId;
|
||||
}
|
||||
|
||||
static fromResult(result: SignInResult): LegacyUserIdentity {
|
||||
return new LegacyUserIdentity(result);
|
||||
}
|
||||
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
return this.result.getIdToken?.();
|
||||
}
|
||||
|
||||
getProfile(): ProfileInfo {
|
||||
return this.result.profile;
|
||||
}
|
||||
|
||||
async getProfileInfo(): Promise<ProfileInfo> {
|
||||
return this.result.profile;
|
||||
}
|
||||
|
||||
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
|
||||
const token = await this.getIdToken();
|
||||
|
||||
if (!token) {
|
||||
const userEntityRef = `user:default/${this.getUserId()}`;
|
||||
return {
|
||||
type: 'user',
|
||||
userEntityRef,
|
||||
ownershipEntityRefs: [userEntityRef],
|
||||
};
|
||||
}
|
||||
|
||||
const { sub, ent } = parseJwtPayload(token);
|
||||
return {
|
||||
type: 'user',
|
||||
userEntityRef: sub,
|
||||
ownershipEntityRefs: ent ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async getCredentials(): Promise<{ token?: string | undefined }> {
|
||||
const token = await this.result.getIdToken?.();
|
||||
return { token };
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
return this.result.signOut?.();
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstageIdentity,
|
||||
BackstageIdentityResponse,
|
||||
configApiRef,
|
||||
SignInPageProps,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { UserIdentity } from './UserIdentity';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
@@ -49,7 +50,7 @@ type SingleSignInPageProps = SignInPageProps & {
|
||||
export type Props = MultiSignInPageProps | SingleSignInPageProps;
|
||||
|
||||
export const MultiSignInPage = ({
|
||||
onResult,
|
||||
onSignInSuccess,
|
||||
providers = [],
|
||||
title,
|
||||
align = 'left',
|
||||
@@ -60,7 +61,7 @@ export const MultiSignInPage = ({
|
||||
const signInProviders = getSignInProviders(providers);
|
||||
const [loading, providerElements] = useSignInProviders(
|
||||
signInProviders,
|
||||
onResult,
|
||||
onSignInSuccess,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -87,9 +88,9 @@ export const MultiSignInPage = ({
|
||||
};
|
||||
|
||||
export const SingleSignInPage = ({
|
||||
onResult,
|
||||
provider,
|
||||
auto,
|
||||
onSignInSuccess,
|
||||
}: SingleSignInPageProps) => {
|
||||
const classes = useStyles();
|
||||
const authApi = useApi(provider.apiRef);
|
||||
@@ -105,47 +106,42 @@ export const SingleSignInPage = ({
|
||||
type LoginOpts = { checkExisting?: boolean; showPopup?: boolean };
|
||||
const login = async ({ checkExisting, showPopup }: LoginOpts) => {
|
||||
try {
|
||||
let identity: BackstageIdentity | undefined;
|
||||
let identityResponse: BackstageIdentityResponse | undefined;
|
||||
if (checkExisting) {
|
||||
// Do an initial check if any logged-in session exists
|
||||
identity = await authApi.getBackstageIdentity({
|
||||
identityResponse = await authApi.getBackstageIdentity({
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
// If no session exists, show the sign-in page
|
||||
if (!identity && (showPopup || auto)) {
|
||||
if (!identityResponse && (showPopup || auto)) {
|
||||
// Unless auto is set to true, this step should not happen.
|
||||
// When user intentionally clicks the Sign In button, autoShowPopup is set to true
|
||||
setShowLoginPage(true);
|
||||
identity = await authApi.getBackstageIdentity({
|
||||
identityResponse = await authApi.getBackstageIdentity({
|
||||
instantPopup: true,
|
||||
});
|
||||
if (!identity) {
|
||||
if (!identityResponse) {
|
||||
throw new Error(
|
||||
`The ${provider.title} provider is not configured to support sign-in`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!identity) {
|
||||
if (!identityResponse) {
|
||||
setShowLoginPage(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await authApi.getProfile();
|
||||
onResult({
|
||||
userId: identity!.id,
|
||||
profile: profile!,
|
||||
getIdToken: () => {
|
||||
return authApi
|
||||
.getBackstageIdentity()
|
||||
.then(i => i!.token ?? i!.idToken);
|
||||
},
|
||||
signOut: async () => {
|
||||
await authApi.signOut();
|
||||
},
|
||||
});
|
||||
onSignInSuccess(
|
||||
UserIdentity.create({
|
||||
identity: identityResponse.identity,
|
||||
authApi,
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
} catch (err: any) {
|
||||
// User closed the sign-in modal
|
||||
setError(err);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { BackstageUserIdentity, ProfileInfo } from '@backstage/core-plugin-api';
|
||||
import { UserIdentity } from './UserIdentity';
|
||||
|
||||
describe('UserIdentity', () => {
|
||||
it('should cache a successful response from the AuthApi for getProfile', async () => {
|
||||
const mockIdentity: BackstageUserIdentity = {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/blam',
|
||||
ownershipEntityRefs: [],
|
||||
};
|
||||
|
||||
const mockProfileInfo: ProfileInfo = {
|
||||
displayName: 'Blam',
|
||||
email: 'blob@boop.com',
|
||||
};
|
||||
|
||||
const mockAuthApi: any = {
|
||||
getProfile: jest.fn().mockResolvedValue(mockProfileInfo),
|
||||
};
|
||||
|
||||
const userIdentity = UserIdentity.create({
|
||||
authApi: mockAuthApi,
|
||||
identity: mockIdentity,
|
||||
});
|
||||
|
||||
await userIdentity.getProfileInfo();
|
||||
await userIdentity.getProfileInfo();
|
||||
|
||||
const response = await userIdentity.getProfileInfo();
|
||||
|
||||
expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(response).toEqual(mockProfileInfo);
|
||||
});
|
||||
|
||||
it('should not cache failures for the AuthApi for getProfile', async () => {
|
||||
const mockIdentity: BackstageUserIdentity = {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/blam',
|
||||
ownershipEntityRefs: [],
|
||||
};
|
||||
|
||||
const mockProfileInfo: ProfileInfo = {
|
||||
displayName: 'Blam',
|
||||
email: 'blob@boop.com',
|
||||
};
|
||||
|
||||
const mockAuthApi: any = {
|
||||
getProfile: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boop'))
|
||||
.mockResolvedValueOnce(mockProfileInfo),
|
||||
};
|
||||
|
||||
const userIdentity = UserIdentity.create({
|
||||
authApi: mockAuthApi,
|
||||
identity: mockIdentity,
|
||||
});
|
||||
|
||||
await expect(() => userIdentity.getProfileInfo()).rejects.toThrow('boop');
|
||||
const response = await userIdentity.getProfileInfo();
|
||||
|
||||
expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(response).toEqual(mockProfileInfo);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 {
|
||||
IdentityApi,
|
||||
ProfileInfo,
|
||||
ProfileInfoApi,
|
||||
BackstageUserIdentity,
|
||||
BackstageIdentityApi,
|
||||
SessionApi,
|
||||
SignInResult,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import { GuestUserIdentity } from './GuestUserIdentity';
|
||||
import { LegacyUserIdentity } from './LegacyUserIdentity';
|
||||
|
||||
/**
|
||||
* An implementation of the IdentityApi that is constructed using
|
||||
* various backstage user identity representations.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class UserIdentity implements IdentityApi {
|
||||
private profilePromise?: Promise<ProfileInfo>;
|
||||
/**
|
||||
* Creates a new IdentityApi that acts as a Guest User.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
static createGuest(): IdentityApi {
|
||||
return new GuestUserIdentity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new IdentityApi using a legacy SignInResult object.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
static fromLegacy(result: SignInResult): IdentityApi {
|
||||
return LegacyUserIdentity.fromResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new IdentityApi implementation using a user identity
|
||||
* and an auth API that will be used to request backstage tokens.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
static create(options: {
|
||||
identity: BackstageUserIdentity;
|
||||
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
|
||||
/**
|
||||
* Passing a profile synchronously allows the deprecated `getProfile` method to be
|
||||
* called by consumers of the {@link @backstage/core-plugin-api#IdentityApi}. If you
|
||||
* do not have any consumers of that method then this is safe to leave out.
|
||||
*
|
||||
* @deprecated Only provide this if you have plugins that call the synchronous `getProfile` method, which is also deprecated.
|
||||
*/
|
||||
profile?: ProfileInfo;
|
||||
}): IdentityApi {
|
||||
return new UserIdentity(options.identity, options.authApi, options.profile);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly identity: BackstageUserIdentity,
|
||||
private readonly authApi: ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi,
|
||||
private readonly profile?: ProfileInfo,
|
||||
) {}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
|
||||
getUserId(): string {
|
||||
const ref = this.identity.userEntityRef;
|
||||
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref);
|
||||
if (!match) {
|
||||
throw new TypeError(`Invalid user entity reference "${ref}"`);
|
||||
}
|
||||
|
||||
return match[3];
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
const identity = await this.authApi.getBackstageIdentity();
|
||||
return identity!.token;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
|
||||
getProfile(): ProfileInfo {
|
||||
if (!this.profile) {
|
||||
throw new Error(
|
||||
'The identity API does not implement synchronous profile fetching, use getProfileInfo() instead',
|
||||
);
|
||||
}
|
||||
return this.profile;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
|
||||
async getProfileInfo(): Promise<ProfileInfo> {
|
||||
if (this.profilePromise) {
|
||||
return await this.profilePromise;
|
||||
}
|
||||
|
||||
try {
|
||||
this.profilePromise = this.authApi.getProfile() as Promise<ProfileInfo>;
|
||||
return await this.profilePromise;
|
||||
} catch (ex) {
|
||||
this.profilePromise = undefined;
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
|
||||
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
|
||||
return this.identity;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
|
||||
async getCredentials(): Promise<{ token?: string | undefined }> {
|
||||
const identity = await this.authApi.getBackstageIdentity();
|
||||
return { token: identity!.token };
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
|
||||
async signOut(): Promise<void> {
|
||||
return this.authApi.signOut();
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,18 @@ import {
|
||||
errorApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { UserIdentity } from './UserIdentity';
|
||||
|
||||
const Component: ProviderComponent = ({ onResult }) => {
|
||||
const Component: ProviderComponent = ({ onSignInSuccess }) => {
|
||||
const auth0AuthApi = useApi(auth0AuthApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const identity = await auth0AuthApi.getBackstageIdentity({
|
||||
const identityResponse = await auth0AuthApi.getBackstageIdentity({
|
||||
instantPopup: true,
|
||||
});
|
||||
if (!identity) {
|
||||
if (!identityResponse) {
|
||||
throw new Error(
|
||||
'The Auth0 provider is not configured to support sign-in',
|
||||
);
|
||||
@@ -44,15 +45,13 @@ const Component: ProviderComponent = ({ onResult }) => {
|
||||
|
||||
const profile = await auth0AuthApi.getProfile();
|
||||
|
||||
onResult({
|
||||
userId: identity!.id,
|
||||
profile: profile!,
|
||||
getIdToken: () =>
|
||||
auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
|
||||
signOut: async () => {
|
||||
await auth0AuthApi.signOut();
|
||||
},
|
||||
});
|
||||
onSignInSuccess(
|
||||
UserIdentity.create({
|
||||
identity: identityResponse.identity,
|
||||
authApi: auth0AuthApi,
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
errorApi.post(new ForwardedError('Auth0 login failed', error));
|
||||
}
|
||||
@@ -77,25 +76,20 @@ const Component: ProviderComponent = ({ onResult }) => {
|
||||
const loader: ProviderLoader = async apis => {
|
||||
const auth0AuthApi = apis.get(auth0AuthApiRef)!;
|
||||
|
||||
const identity = await auth0AuthApi.getBackstageIdentity({
|
||||
const identityResponse = await auth0AuthApi.getBackstageIdentity({
|
||||
optional: true,
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
if (!identityResponse) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const profile = await auth0AuthApi.getProfile();
|
||||
|
||||
return {
|
||||
userId: identity.id,
|
||||
profile: profile!,
|
||||
getIdToken: () =>
|
||||
auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
|
||||
signOut: async () => {
|
||||
await auth0AuthApi.signOut();
|
||||
},
|
||||
};
|
||||
return UserIdentity.create({
|
||||
identity: identityResponse.identity,
|
||||
authApi: auth0AuthApi,
|
||||
profile,
|
||||
});
|
||||
};
|
||||
|
||||
export const auth0Provider: SignInProvider = { Component, loader };
|
||||
|
||||
@@ -27,36 +27,33 @@ import {
|
||||
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { GridItem } from './styles';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { UserIdentity } from './UserIdentity';
|
||||
|
||||
const Component: ProviderComponent = ({ config, onResult }) => {
|
||||
const Component: ProviderComponent = ({ config, onSignInSuccess }) => {
|
||||
const { apiRef, title, message } = config as SignInProviderConfig;
|
||||
const authApi = useApi(apiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const identity = await authApi.getBackstageIdentity({
|
||||
const identityResponse = await authApi.getBackstageIdentity({
|
||||
instantPopup: true,
|
||||
});
|
||||
if (!identity) {
|
||||
if (!identityResponse) {
|
||||
throw new Error(
|
||||
`The ${title} provider is not configured to support sign-in`,
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await authApi.getProfile();
|
||||
onResult({
|
||||
userId: identity!.id,
|
||||
profile: profile!,
|
||||
getIdToken: () => {
|
||||
return authApi
|
||||
.getBackstageIdentity()
|
||||
.then(i => i!.token ?? i!.idToken);
|
||||
},
|
||||
signOut: async () => {
|
||||
await authApi.signOut();
|
||||
},
|
||||
});
|
||||
|
||||
onSignInSuccess(
|
||||
UserIdentity.create({
|
||||
identity: identityResponse.identity,
|
||||
profile,
|
||||
authApi,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
errorApi.post(new ForwardedError('Login failed', error));
|
||||
}
|
||||
@@ -82,25 +79,21 @@ const Component: ProviderComponent = ({ config, onResult }) => {
|
||||
const loader: ProviderLoader = async (apis, apiRef) => {
|
||||
const authApi = apis.get(apiRef)!;
|
||||
|
||||
const identity = await authApi.getBackstageIdentity({
|
||||
const identityResponse = await authApi.getBackstageIdentity({
|
||||
optional: true,
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
if (!identityResponse) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const profile = await authApi.getProfile();
|
||||
|
||||
return {
|
||||
userId: identity.id,
|
||||
profile: profile!,
|
||||
getIdToken: () =>
|
||||
authApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
|
||||
signOut: async () => {
|
||||
await authApi.signOut();
|
||||
},
|
||||
};
|
||||
return UserIdentity.create({
|
||||
identity: identityResponse.identity,
|
||||
profile,
|
||||
authApi,
|
||||
});
|
||||
};
|
||||
|
||||
export const commonProvider: SignInProvider = { Component, loader };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user