diff --git a/.changeset/cost-insights-five-baboons-attack.md b/.changeset/cost-insights-five-baboons-attack.md new file mode 100644 index 0000000000..f2c070acb4 --- /dev/null +++ b/.changeset/cost-insights-five-baboons-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +make change ratio optional diff --git a/.changeset/dry-elephants-doubt.md b/.changeset/dry-elephants-doubt.md deleted file mode 100644 index 7a9ccc9892..0000000000 --- a/.changeset/dry-elephants-doubt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-github-deployments': patch ---- - -Adds extraColumns field to GitHub Deployments card diff --git a/.changeset/eight-plums-hide.md b/.changeset/eight-plums-hide.md deleted file mode 100644 index d1f8bcecfe..0000000000 --- a/.changeset/eight-plums-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Forward user token to scaffolder task for subsequent api requests diff --git a/.changeset/fair-carrots-tell.md b/.changeset/fair-carrots-tell.md deleted file mode 100644 index 62dce5c3ce..0000000000 --- a/.changeset/fair-carrots-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Add `config:docs` command that opens up reference documentation for the local configuration schema in a browser. diff --git a/.changeset/flat-paws-rule.md b/.changeset/flat-paws-rule.md deleted file mode 100644 index af7725793e..0000000000 --- a/.changeset/flat-paws-rule.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/plugin-scaffolder': patch ---- - -Adding Headings for Accessibility on the Scaffolder Plugin diff --git a/.changeset/fluffy-suns-repair.md b/.changeset/fluffy-suns-repair.md deleted file mode 100644 index 6f45905219..0000000000 --- a/.changeset/fluffy-suns-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Respect top-level UI schema keys in scaffolder forms. Allows more advanced RJSF features such as explicit field ordering. diff --git a/.changeset/fresh-cheetahs-rush.md b/.changeset/fresh-cheetahs-rush.md deleted file mode 100644 index fe8a9caafb..0000000000 --- a/.changeset/fresh-cheetahs-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -No longer add newly created plugins to `plugins.ts` in the app, as it is no longer needed. diff --git a/.changeset/good-glasses-build.md b/.changeset/good-glasses-build.md deleted file mode 100644 index 3d506d3e43..0000000000 --- a/.changeset/good-glasses-build.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-api': patch -'@backstage/core': patch ---- - -Add support for discovering plugins through the app element tree, removing the need to register them explicitly. diff --git a/.changeset/healthy-stingrays-shout.md b/.changeset/healthy-stingrays-shout.md deleted file mode 100644 index c3ce59ce1f..0000000000 --- a/.changeset/healthy-stingrays-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -SystemDiagramCard UI improvements diff --git a/.changeset/moody-pigs-repeat.md b/.changeset/moody-pigs-repeat.md deleted file mode 100644 index 396fa80281..0000000000 --- a/.changeset/moody-pigs-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Exported SignInProviderConfig to strongly type SignInPage providers diff --git a/.changeset/quiet-badgers-cheer.md b/.changeset/quiet-badgers-cheer.md deleted file mode 100644 index e22bf9492c..0000000000 --- a/.changeset/quiet-badgers-cheer.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Removed `plugins.ts` from the app, as plugins are now discovered through the react tree. - -To apply this change to an existing app, simply delete `packages/app/src/plugins.ts` along with the import and usage in `packages/app/src/App.tsx`. - -Note that there are a few plugins that require explicit registration, in which case you would need to keep them in `plugins.ts`. The set of plugins that need explicit registration is any plugin that doesn't have a component extension that gets rendered as part of the app element tree. An example of such a plugin in the main Backstage repo is `@backstage/plugin-badges`. In the case of the badges plugin this is because there is not yet a component-based API for adding context menu items to the entity layout. - -If you have plugins that still rely on route registration through the `register` method of `createPlugin`, these need to be kept in `plugins.ts` as well. However, it is recommended to migrate these to export an extensions component instead. diff --git a/.changeset/real-apples-visit.md b/.changeset/real-apples-visit.md deleted file mode 100644 index b5165f633b..0000000000 --- a/.changeset/real-apples-visit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -GithubDiscoveryProcessor now excludes archived repositories so they won't be added to Backstage. diff --git a/.changeset/rude-items-bow.md b/.changeset/rude-items-bow.md deleted file mode 100644 index 69b76d821e..0000000000 --- a/.changeset/rude-items-bow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes-backend': patch ---- - -Kubernetes client TLS verification is now configurable and defaults to true diff --git a/.changeset/six-turtles-sip.md b/.changeset/six-turtles-sip.md deleted file mode 100644 index e71b04e64d..0000000000 --- a/.changeset/six-turtles-sip.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Support configuration of file storage for SQLite databases. Every plugin has its -own database file at the specified path. diff --git a/.changeset/sour-plums-enjoy.md b/.changeset/sour-plums-enjoy.md deleted file mode 100644 index ef68166255..0000000000 --- a/.changeset/sour-plums-enjoy.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Fix system diagram card to be on the system page - -To apply the same fix to an existing application, in `EntityPage.tsx` simply move the `` for the `/diagram` path from the `groupPage` down into the `systemPage` element. diff --git a/.changeset/spotty-pigs-bathe.md b/.changeset/spotty-pigs-bathe.md new file mode 100644 index 0000000000..0585f335dc --- /dev/null +++ b/.changeset/spotty-pigs-bathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +Update README for composability diff --git a/.changeset/stale-carpets-poke.md b/.changeset/stale-carpets-poke.md deleted file mode 100644 index 4f39e57b71..0000000000 --- a/.changeset/stale-carpets-poke.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Externalize repository processing for BitbucketDiscoveryProcessor. - -Add an extension point where you can customize how a matched Bitbucket repository should -be processed. This can for example be used if you want to generate the catalog-info.yaml -automatically based on other files in a repository, while taking advantage of the -build-in repository crawling functionality. - -`BitbucketDiscoveryProcessor.fromConfig` now takes an optional parameter `options.parser` where -you can customize the logic for each repository found. The default parser has the same -behaviour as before, where it emits an optional location for the matched repository -and lets the other processors take care of further processing. - -```typescript -const customRepositoryParser: BitbucketRepositoryParser = async function* customRepositoryParser({ - client, - repository, -}) { - // Custom logic for interpret the matching repository. - // See defaultRepositoryParser for an example -}; - -const processor = BitbucketDiscoveryProcessor.fromConfig(env.config, { - parser: customRepositoryParser, - logger: env.logger, -}); -``` diff --git a/.changeset/stale-chefs-retire.md b/.changeset/stale-chefs-retire.md deleted file mode 100644 index e43c20062a..0000000000 --- a/.changeset/stale-chefs-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Adding close button on support menu diff --git a/.changeset/techdocs-eight-camels-hear.md b/.changeset/techdocs-eight-camels-hear.md deleted file mode 100644 index 8f2479f6bd..0000000000 --- a/.changeset/techdocs-eight-camels-hear.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-techdocs': minor ---- - -Add feedback link icon in Techdocs Reader that directs to GitLab or GitHub repo issue page with pre-filled title and source link. -For link to appear, requires `repo_url` and `edit_uri` to be filled in mkdocs.yml, as per https://www.mkdocs.org/user-guide/configuration. An `edit_uri` will need to be specified for self-hosted GitLab/GitHub instances with a different host name. -To identify issue URL format as GitHub or GitLab, the host name of source in `repo_url` is checked if it contains `gitlab` or `github`. Alternately this is determined by matching to `host` values from `integrations` in app-config.yaml. diff --git a/.changeset/techdocs-swift-mugs-invent.md b/.changeset/techdocs-swift-mugs-invent.md deleted file mode 100644 index 912089fb3c..0000000000 --- a/.changeset/techdocs-swift-mugs-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Add a test id to the shadow root element of the Reader to access it easily in e2e tests diff --git a/.changeset/thick-cobras-switch.md b/.changeset/thick-cobras-switch.md new file mode 100644 index 0000000000..002c724421 --- /dev/null +++ b/.changeset/thick-cobras-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-proxy-backend': patch +--- + +Prefix proxy routes with `/` if not present in configuration diff --git a/.changeset/tiny-ears-love.md b/.changeset/tiny-ears-love.md deleted file mode 100644 index 9c3d1613a5..0000000000 --- a/.changeset/tiny-ears-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Added support for Datadog rum events diff --git a/.changeset/tough-walls-wash.md b/.changeset/tough-walls-wash.md new file mode 100644 index 0000000000..a45adcfbf6 --- /dev/null +++ b/.changeset/tough-walls-wash.md @@ -0,0 +1,9 @@ +--- +'@backstage/catalog-model': patch +'@backstage/cli': patch +'@backstage/config-loader': patch +'@backstage/config': patch +'@backstage/plugin-scaffolder': patch +--- + +Bump `json-schema` dependency from `0.2.5` to `0.3.0`. diff --git a/.changeset/tricky-yaks-melt.md b/.changeset/tricky-yaks-melt.md new file mode 100644 index 0000000000..3679e6d1da --- /dev/null +++ b/.changeset/tricky-yaks-melt.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +--- + +Adding optional config to enable S3-like API for tech-docs using s3ForcePathStyle option. +This allows providers like LocalStack, Minio and Wasabi (+possibly others) to be used to host tech docs. diff --git a/.changeset/twenty-peas-deny.md b/.changeset/twenty-peas-deny.md new file mode 100644 index 0000000000..d149a9337c --- /dev/null +++ b/.changeset/twenty-peas-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Allow `filter` parameter to be specified multiple times diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4e21603c63..b07519824b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,12 +8,16 @@ /docs/features/techdocs @backstage/techdocs-core /docs/features/search @backstage/techdocs-core /docs/assets/search @backstage/techdocs-core +/plugins/code-coverage @alde @nissayeva +/plugins/code-coverage-backend @alde @nissayeva /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios /plugins/search @backstage/techdocs-core /plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/packages/search-common @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining +/.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7d0f36d858..694226b8cc 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -10,6 +10,7 @@ Chai Changesets Chanwit Cloudformation +Cobertura Codecov Codehilite Config @@ -17,10 +18,12 @@ Datadog Debounce Discoverability Dockerfile +dockerfiles Dockerize Docusaurus Env Expedia +Fargate Figma Firekube Fiverr @@ -32,13 +35,16 @@ Hackathons Heroku Hostname Iain +JaCoCo JavaScript Kaewkasi Knex Leasot Lerna +LocalStack Luxon Minikube +Minio Mkdocs Monorepo Namespaces @@ -76,6 +82,7 @@ Tolerations WWW Weaveworks Webpack +XML Zalando Zhou abc diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index bb14e1c38b..89df848c76 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -48,5 +48,7 @@ jobs: - uses: chromaui/action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} + # projetToken intentionally shared to allow collaborators to run Chromatic on forks + # https://www.chromatic.com/docs/custom-ci-provider#run-chromatic-on-external-forks-of-open-source-projects projectToken: 9tzak77m9nj storybookBuildDir: 'packages/storybook/dist' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2337a70e2c..0a6d0f8589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - name: type checking and declarations run: yarn tsc:full + - name: check api reports + run: yarn build:api-reports:only --ci + - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn lerna -- run build --since origin/master --include-dependencies diff --git a/.prettierignore b/.prettierignore index c4e675a5d7..e60d54a466 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ microsite coverage *.hbs templates +api-report.md plugins/scaffolder-backend/sample-templates .vscode dist-types diff --git a/app-config.yaml b/app-config.yaml index ac59874878..3c4f2d7ea5 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -2,9 +2,9 @@ app: title: Backstage Example App baseUrl: http://localhost:3000 googleAnalyticsTrackingId: # UA-000000-0 - datadogRum: - clientToken: '123456789' - applicationId: qwerty + #datadogRum: + # clientToken: '123456789' + # applicationId: qwerty # site: # datadoghq.eu default = datadoghq.com # env: # optional diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md index 7d04a2cf1e..40bb2666bc 100644 --- a/contrib/chart/backstage/README.md +++ b/contrib/chart/backstage/README.md @@ -129,6 +129,16 @@ For the CA, create a `configMap` named `--postgres-ca` kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt" ``` +or disable CA mount + +```yaml +backend: + postgresCertMountEnabled: false + +lighthouse: + postgresCertMountEnabled: false +``` + > Where the release name contains the chart name "backstage" then only the release name will be used. Now install the helm chart: diff --git a/contrib/chart/backstage/templates/_helpers.tpl b/contrib/chart/backstage/templates/_helpers.tpl index 5d49ba6975..123d4dba5b 100644 --- a/contrib/chart/backstage/templates/_helpers.tpl +++ b/contrib/chart/backstage/templates/_helpers.tpl @@ -214,7 +214,7 @@ Postgres port for the backend {{- .Values.postgresql.service.port }} {{- else if .Values.appConfig.backend.database.connection.port -}} {{- .Values.appConfig.backend.database.connection.port }} -{{ else }} +{{- else -}} 5432 {{- end -}} {{- end -}} diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml index 144fc93e62..0fa6b7fbe2 100644 --- a/contrib/chart/backstage/templates/backend-deployment.yaml +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -26,6 +26,13 @@ spec: {{- end}} containers: - name: {{ .Chart.Name }}-backend + command: ["node"] + args: + - "packages/backend" + - "--config" + - "app-config.yaml" + - "--config" + - {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) | quote }} image: {{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }} imagePullPolicy: {{ .Values.backend.image.pullPolicy }} ports: @@ -49,16 +56,20 @@ spec: name: {{ include "backend.postgresql.passwordSecret" .}} key: postgresql-password volumeMounts: + {{- if .Values.backend.postgresCertMountEnabled }} - name: postgres-ca mountPath: {{ include "backstage.backend.postgresCaDir" . }} + {{- end }} - name: app-config mountPath: {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) }} subPath: {{ include "backstage.appConfigFilename" . }} volumes: + {{- if .Values.backend.postgresCertMountEnabled }} - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + {{- end }} - name: app-config configMap: name: {{ include "backstage.fullname" . }}-app-config @@ -83,5 +94,5 @@ spec: app: backstage component: backend - type: ClusterIP + type: {{ .Values.backend.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/frontend-deployment.yaml b/contrib/chart/backstage/templates/frontend-deployment.yaml index 9c674cc160..04983513a6 100644 --- a/contrib/chart/backstage/templates/frontend-deployment.yaml +++ b/contrib/chart/backstage/templates/frontend-deployment.yaml @@ -1,3 +1,4 @@ +{{- if .Values.frontend.enabled }} apiVersion: apps/v1 kind: Deployment metadata: @@ -46,7 +47,6 @@ spec: {{- if .Values.global.nodeSelector }} nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} {{- end }} -{{- if .Values.frontend.enabled }} --- apiVersion: v1 kind: Service @@ -62,5 +62,5 @@ spec: app: backstage component: frontend - type: ClusterIP + type: {{ .Values.frontend.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/lighthouse-config.yaml b/contrib/chart/backstage/templates/lighthouse-config.yaml index fdf4bd8b06..7b8cad42dc 100644 --- a/contrib/chart/backstage/templates/lighthouse-config.yaml +++ b/contrib/chart/backstage/templates/lighthouse-config.yaml @@ -1,3 +1,4 @@ +{{- if .Values.lighthouse.enabled }} apiVersion: v1 kind: ConfigMap metadata: @@ -7,4 +8,5 @@ data: PGUSER: {{ include "lighthouse.postgresql.user" . | quote }} PGPORT: {{ include "lighthouse.postgresql.port" . | quote }} PGHOST: {{ include "lighthouse.postgresql.host" . | quote }} - PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} \ No newline at end of file + PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} +{{- end }} \ No newline at end of file diff --git a/contrib/chart/backstage/templates/lighthouse-deployment.yaml b/contrib/chart/backstage/templates/lighthouse-deployment.yaml index d6e1ce19dd..849c48d7a6 100644 --- a/contrib/chart/backstage/templates/lighthouse-deployment.yaml +++ b/contrib/chart/backstage/templates/lighthouse-deployment.yaml @@ -51,14 +51,18 @@ spec: name: {{ include "lighthouse.postgresql.passwordSecret" . }} key: postgresql-password + {{- if .Values.lighthouse.postgresCertMountEnabled }} volumeMounts: - name: postgres-ca mountPath: {{ include "backstage.lighthouse.postgresCaDir" . }} + {{- end }} + {{- if .Values.lighthouse.postgresCertMountEnabled }} volumes: - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + {{- end }} {{- if .Values.global.nodeSelector }} nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} @@ -78,5 +82,5 @@ spec: app: backstage component: lighthouse-audit-service - type: ClusterIP + type: {{ .Values.lighthouse.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml new file mode 100644 index 0000000000..a71cef7b21 --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml @@ -0,0 +1,15 @@ +{{- if not .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ include "backend.postgresql.passwordSecret" . }} + labels: + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install,pre-upgrade" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: + postgresql-password: {{ .Values.appConfig.backend.database.connection.password | b64enc }} +{{- end }} diff --git a/contrib/chart/backstage/templates/postgresql-password-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml similarity index 51% rename from contrib/chart/backstage/templates/postgresql-password-secret.yaml rename to contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml index e53369adb9..e7374e70db 100644 --- a/contrib/chart/backstage/templates/postgresql-password-secret.yaml +++ b/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml @@ -1,18 +1,4 @@ -{{- if not .Values.postgresql.enabled }} ---- -apiVersion: v1 -kind: Secret -type: Opaque -metadata: - name: {{ include "backend.postgresql.passwordSecret" . }} - labels: - release: {{ .Release.Name }} - annotations: - "helm.sh/hook": "pre-install,pre-upgrade" - "helm.sh/hook-delete-policy": "before-hook-creation" -data: - postgresql-password: {{ .Values.appConfig.backend.database.connection.password | b64enc }} -{{- end }} +{{- if .Values.lighthouse.enabled }} {{- if not .Values.postgresql.enabled }} --- apiVersion: v1 @@ -28,3 +14,4 @@ metadata: data: postgresql-password: {{ .Values.lighthouse.database.connection.password | b64enc }} {{- end }} +{{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index bd80bc22b6..9f70dac6dc 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -3,13 +3,14 @@ # Declare variables to be passed into your templates. frontend: - enabled: true + enabled: false replicaCount: 1 image: repository: martinaif/backstage-k8s-demo-frontend tag: test1 pullPolicy: IfNotPresent containerPort: 80 + serviceType: ClusterIP resources: requests: memory: 128Mi @@ -23,9 +24,11 @@ backend: replicaCount: 1 image: repository: martinaif/backstage-k8s-demo-backend - tag: test1 + tag: 20210423T1550 pullPolicy: IfNotPresent containerPort: 7000 + serviceType: ClusterIP + postgresCertMountEnabled: true resources: requests: memory: 512Mi @@ -40,6 +43,8 @@ lighthouse: tag: latest pullPolicy: IfNotPresent containerPort: 3003 + serviceType: ClusterIP + postgresCertMountEnabled: true resources: requests: memory: 128Mi diff --git a/contrib/docker/frontend-with-nginx/Dockerfile b/contrib/docker/frontend-with-nginx/Dockerfile deleted file mode 100644 index a444c9de83..0000000000 --- a/contrib/docker/frontend-with-nginx/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM nginx:mainline - -# The purpose of this image is to serve the frontend app content separately. -# By default the Backstage backend uses the app-backend plugin to serve the -# app from the backend itself, but it may be desirable to move the frontend -# content serving to a separate deployment, in which case this image can be used. - -# This dockerfile requires the app to be built on the host first, as it -# simply copies in the build output into the image. - -RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* - -COPY packages/app/dist /usr/share/nginx/html -COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template -COPY docker/run.sh /usr/local/bin/run.sh -CMD run.sh - -ENV PORT 80 diff --git a/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild b/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild new file mode 100644 index 0000000000..41f931544f --- /dev/null +++ b/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild @@ -0,0 +1,58 @@ +# The purpose of this image is to serve the frontend app content separately. +# By default the Backstage backend uses the app-backend plugin to serve the +# app from the backend itself, but it may be desirable to move the frontend +# content serving to a separate deployment, in which case this image can be +# used. + +# This dockerfile also performs the build first inside docker. This may come +# with a build time impact, but is sometimes desirable. If you want to run the +# build on the host instead, use the file simply named Dockerfile in this folder +# instead. + +# USAGE: +# +# - Copy this file and the "docker" folder from this directory to your project +# root +# +# - Update your .dockerignore, make sure that the source folders are not +# excluded, but do exclude node_modules and build artifacts: +# +# .git +# node_modules +# packages/*/dist +# packages/*/node_modules +# plugins/*/dist +# plugins/*/node_modules +# +# - Update the copy of this file to add configuration arguments to the "build" +# command, for example: +# +# RUN yarn workspace app build --config --config ... +# +# - In your project root, run: +# +# docker build -t backstage-frontend -f Dockerfile.dockerbuild . + + + +FROM node:14-buster AS build + +RUN mkdir /app +COPY . /app +WORKDIR /app + +RUN yarn install +RUN yarn workspace app build + + + +FROM nginx:mainline + +RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/packages/app/dist /usr/share/nginx/html +COPY docker/default.conf.template /etc/nginx/templates/default.conf.template + +COPY docker/inject-config.sh /docker-entrypoint.d/40-inject-config.sh + +ENV PORT 80 diff --git a/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild b/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild new file mode 100644 index 0000000000..1e0134017f --- /dev/null +++ b/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild @@ -0,0 +1,41 @@ +# The purpose of this image is to serve the frontend app content separately. +# By default the Backstage backend uses the app-backend plugin to serve the +# app from the backend itself, but it may be desirable to move the frontend +# content serving to a separate deployment, in which case this image can be +# used. + +# This dockerfile requires the app to be built on the host first, as it +# simply copies in the build output into the image. If you want to also perform +# the build itself inside docker, use Dockerfile.build in this folder instead. + + +# USAGE: +# +# - Copy this file and the "docker" folder from this directory to your project +# root +# +# - Add the following line to your .dockerignore to make sure that the built +# frontend actually can be transferred into the docker image: +# +# !packages/app/dist +# +# - In your project root, run: +# +# yarn install +# yarn tsc +# yarn build --config --config ... +# docker build -t backstage-frontend -f Dockerfile.hostbuild . + + + +FROM nginx:mainline + +RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* + +COPY packages/app/dist /usr/share/nginx/html +COPY docker/default.conf.template /etc/nginx/templates/default.conf.template + +COPY docker/inject-config.sh /docker-entrypoint.d/40-inject-config.sh + +ENV PORT 80 + diff --git a/contrib/docker/frontend-with-nginx/README.md b/contrib/docker/frontend-with-nginx/README.md new file mode 100644 index 0000000000..eae70b8bba --- /dev/null +++ b/contrib/docker/frontend-with-nginx/README.md @@ -0,0 +1,16 @@ +# Frontend with NGINX + +This folder contains Docker images that let you run the Backstage frontend as +a separate image, rather than having it served through the `app-backend` plugin +from the backend. + +Note that when running the frontend like this, the app configuration becomes +embedded into the actual static JavaScript files at build time. This means that +you will have to supply the list of configuration files as part of the command +line at build. + +## Usage + +There are two variants: one that builds inside Docker, and one that builds on +the host. See the comments at the top of the individual dockerfiles for usage +instructions. diff --git a/contrib/docker/frontend-with-nginx/docker/run.sh b/contrib/docker/frontend-with-nginx/docker/inject-config.sh similarity index 80% rename from contrib/docker/frontend-with-nginx/docker/run.sh rename to contrib/docker/frontend-with-nginx/docker/inject-config.sh index fdb1742a07..a0cea96ff3 100755 --- a/contrib/docker/frontend-with-nginx/docker/run.sh +++ b/contrib/docker/frontend-with-nginx/docker/inject-config.sh @@ -2,13 +2,6 @@ set -Eeuo pipefail -# Run nginx as root -sed -i 's/user nginx.*$//' /etc/nginx/nginx.conf - -# Write selected env vars to nginx config -envsubst '$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - -# Inject runtime config into the client function inject_config() { # Read runtime config from env in the same way as the @backstage/config-loader package local config @@ -41,4 +34,3 @@ function inject_config() { inject_config -exec nginx -g 'daemon off;' diff --git a/contrib/docker/multi-stage-frontend/Dockerfile b/contrib/docker/multi-stage-frontend/Dockerfile deleted file mode 100644 index 3190687c52..0000000000 --- a/contrib/docker/multi-stage-frontend/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:12-buster AS build - -RUN mkdir /app -COPY . /app -WORKDIR /app - -RUN yarn install -RUN yarn workspace example-app build - -# Contruct backstage-frontend image -FROM nginx:mainline - -RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* - -# Copy from build stage -COPY --from=build /app/packages/app/dist /usr/share/nginx/html - -COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template -COPY docker/run.sh /usr/local/bin/run.sh - -CMD run.sh - -ENV PORT 80 diff --git a/contrib/docker/multi-stage-frontend/README.md b/contrib/docker/multi-stage-frontend/README.md deleted file mode 100644 index b6ae4fdc6c..0000000000 --- a/contrib/docker/multi-stage-frontend/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Standalone Dockerfile for frontend - -This directory contains the resources which will help you build backstage without any requirements -other than docker itself. It uses a multi-stage Dockerfile to build and ship backstage. - -## Usage - -You can simply run the following command to build backstage. - -``` -# Make sure you are in the root directory of backstage then run -docker build -t backstage-frontend -f ./contrib/docker/multi-stage-frontend/Dockerfile . -``` - -After a successful build, You can simply run backstage frontend with the following command. - -``` -docker run -it --rm -p 3080:80 backstage-frontend -``` diff --git a/contrib/docs/tutorials/aws-fargate-deployment.md b/contrib/docs/tutorials/aws-fargate-deployment.md new file mode 100644 index 0000000000..bc530441c6 --- /dev/null +++ b/contrib/docs/tutorials/aws-fargate-deployment.md @@ -0,0 +1,16 @@ +# Deploying Backstage with AWS Fargate and Aurora + +There are any number of ways to deploy backstage containers on AWS. One of the +simplest from a management and ops perspective is to leverage AWS Fargate and +Aurora PostgreSQL, where both the container orchestration and database clusters +are AWS managed services. + +However, to use them properly there are a large number of supporting resources +(VPCs, Security Groups, Load Balancers, Certificate, etc) required. One approach +is to use [AWS Cloud Development Kit (CDK)](https://aws.amazon.com/cdk/) to +simplify and automate the creation of the entire infrastructure stack along with +automating the build and deploy of the container. + +Check out the [Backstage On AWS](https://github.com/rbogle/backstage-on-aws) +repository, for a ready to use CDK application for deploying your custom +Backstage app onto AWS ECS Fargate and Aurora. diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml index 77a113ae5c..e74bfe0b8d 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml @@ -16,7 +16,7 @@ spec: app.kubernetes.io/name: {{ include "backstage.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} -{{- if .Values.app.enabled }} +{{- if .Values.backend.enabled }} --- apiVersion: v1 kind: Service diff --git a/contrib/terraform/techdocs-s3-storage/readme.md b/contrib/terraform/techdocs-s3-storage/readme.md new file mode 100644 index 0000000000..9615cc8d13 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/readme.md @@ -0,0 +1 @@ +This terraform file should create a S3 bucket and setup IAM with a user with an inline policy which gives the user access to the bucket. After you have created the bucket, user and policy you should go to the user in the AWS console and create an access key. This access key should be used as the env variables in step 3a [here](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs). diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf new file mode 100644 index 0000000000..f256663f43 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -0,0 +1,81 @@ +#========================== +# Variables +#========================== + +variable "backstage_bucket" { + default = "backstage_bucket_for_my_corp" +} + +variable "backstage_iam" { + default = "backstage" +} + +variable "shared_managed_tag" { + default = "terraform_for_my_corp" +} + +#========================== +# Bucket +#========================== + +resource "aws_s3_bucket" "backstage" { + bucket = var.backstage_bucket + acl = "private" + provider = aws + + lifecycle { + prevent_destroy = true + } + + server_side_encryption_configuration { + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } + } + + tags = { + Name = var.backstage_bucket + "Managed By Terraform" = var.shared_managed_tag + } +} + +resource "aws_s3_bucket_public_access_block" "backstage" { + bucket = aws_s3_bucket.backstage.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + + +#========================== +# IAM +#========================== + +resource "aws_iam_user" "backstage" { + name = var.backstage_iam +} + +resource "aws_iam_user_policy" "backstage" { + name = var.backstage_iam + user = aws_iam_user.backstage.name + policy = data.aws_iam_policy_document.backstage_policy.json +} + +data "aws_iam_policy_document" "backstage_policy" { + statement { + actions = [ + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket" + ] + effect = "Allow" + resources = [ + "${aws_s3_bucket.backstage.arn}", + "${aws_s3_bucket.backstage.arn}/*", + ] + } +} diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts new file mode 100644 index 0000000000..5ee077502a --- /dev/null +++ b/cypress/src/integration/integrations.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('Integrations', () => { + describe('ReadTree', () => { + it('should work for github', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://github.com/backstage-verification/test-repo/blob/main/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'github-repo'); + cy.get('table').should('contain', 'github-repo-nested'); + }); + + // it('should work for azure', () => { + // cy.loginAsGuest(); + + // cy.request('POST', '/api/catalog/locations', { + // target: + // 'https://dev.azure.com/backstage-verification/_git/test-repo?path=*', + // type: 'url', + // }); + // }); + + it('should work for gitlab', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://gitlab.com/backstage-verification/test-repo/-/tree/master/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'gitlab-repo'); + cy.get('table').should('contain', 'gitlab-repo-nested'); + }); + + it('should work for bitbucket', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://bitbucket.org/backstage-verification/test-repo/src/master/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'bitbucket-repo'); + cy.get('table').should('contain', 'bitbucket-repo-nested'); + }); + }); +}); diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index ef178b2977..383cf0615a 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -232,7 +232,7 @@ package, which is done as follows: Once the `app-backend` is removed from the backend, you can use your favorite static file serving method for serving the frontend. An example of how to set up an NGINX image is available in the -[contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx/Dockerfile) +[contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx) Note that if you're building a separate docker build of the frontend you probably need to adjust `.dockerignore` appropriately. Most likely by making diff --git a/docs/deployment/helm.md b/docs/deployment/helm.md index fd36aeb81f..cbfcb1bc57 100644 --- a/docs/deployment/helm.md +++ b/docs/deployment/helm.md @@ -1,12 +1,10 @@ --- id: helm -title: Deploying Backstage with Helm +title: Deploying with Helm description: How to deploy Backstage with Helm and Kubernetes sidebar_label: Helm --- -# Helm charts - An example Backstage app can be deployed in Kubernetes using the [Backstage Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage). diff --git a/docs/deployment/index.md b/docs/deployment/index.md index db4ceca6f1..86ad03d1ae 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -30,7 +30,9 @@ There is also an example of deploying on [Heroku](heroku.md), which only requires the first two steps. An example of deploying Backstage with a [Helm chart](helm.md), a common pattern -in AWS, is also available. +in AWS, is also available. There is also a contrib guide to deploying Backstage +with +[AWS Fargate and Aurora PostgreSQL](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-fargate-deployment.md) Please consider contributing other deployment guides if you get Backstage set up on common infrastructure, it would be a great benefit to the community. diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 9dbf447740..2c52e0e308 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -357,6 +357,16 @@ spec: name: postgres-secrets - secretRef: name: backstage-secrets +# Uncomment if health checks are enabled in your app: +# https://backstage.io/docs/plugins/observability#health-checks +# readinessProbe: +# httpGet: +# port: 7000 +# path: /healthcheck +# livenessProbe: +# httpGet: +# port: 7000 +# path: /healthcheck ``` For production deployments, the `image` reference will usually be a full URL to diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index fed24c58d6..10968b427c 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -23,16 +23,8 @@ cd packages/app yarn add @backstage/plugin-kubernetes ``` -Once the package has been installed, you need to import the plugin in your app. -Add the following to `packages/app/src/plugins.ts`: - -`plugins.ts`: - -```typescript -export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; -``` - -Now, add the "Kubernetes" tab to the catalog entity page. In +Once the package has been installed, you need to import the plugin in your app +by adding the "Kubernetes" tab to the catalog entity page. In `packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get to the tab, and add the tab itself. diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index f4e1f86dd8..eb087a9c5f 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -44,7 +44,7 @@ metadata: name: artist-web description: The place to be, for great artists labels: - system: public-websites + example.com/custom: custom_label_value annotations: example.com/service-discovery: artistweb circleci.com/project-slug: github/example-org/artist-website @@ -58,6 +58,7 @@ spec: type: website lifecycle: production owner: artist-relations-team + system: public-websites ``` This is the same entity as returned in JSON from the software catalog API: @@ -76,7 +77,7 @@ This is the same entity as returned in JSON from the software catalog API: "etag": "ZjU2MWRkZWUtMmMxZS00YTZiLWFmMWMtOTE1NGNiZDdlYzNk", "generation": 1, "labels": { - "system": "public-websites" + "example.com/custom": "custom_label_value" }, "links": [{ "url": "https://admin.example-org.com", @@ -90,7 +91,8 @@ This is the same entity as returned in JSON from the software catalog API: "spec": { "lifecycle": "production", "owner": "artist-relations-team", - "type": "website" + "type": "website", + "system": "public-websites" } } ``` @@ -639,22 +641,11 @@ A list of strings that can be associated with the template, e.g. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. -### `spec.type` [optional] +### `spec.type` [required] -The type of component as a string, e.g. `website`. This field is optional but -recommended. - -The software catalog accepts any type value, but an organization should take -great care to establish a proper taxonomy for these. Tools including Backstage -itself may read this field and behave differently depending on its value. For -example, a website type component may present tooling in the Backstage interface -that is specific to just websites. - -The current set of well-known and common values for this field is: - -- `service` - a backend service, typically exposing an API -- `website` - a website -- `library` - a software library, such as an npm module or a Java library +The type of component created by the template, e.g. `website`. This is used for +filtering templates, and should ideally match the Component +[spec.type](#spectype-required) created by the template. ### `spec.parameters` [required] diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index ff8dfbda68..df473f886f 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -24,14 +24,8 @@ yarn add @backstage/plugin-catalog ### Adding the Plugin to your `packages/app` -Add the following entry to the head of your `packages/app/src/plugins.ts`: - -```ts -export { catalogPlugin } from '@backstage/plugin-catalog'; -``` - -Next we need to install the two pages that the catalog plugin provides. You can -choose any name for these routes, but we recommend the following: +Add the two pages that the catalog plugin provides to your app. You can choose +any name for these routes, but we recommend the following: ```tsx // packages/app/src/App.tsx diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 0053c14b79..67b92bd941 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -273,6 +273,20 @@ project within your organization. Specifying this annotation may enable SonarQube related features in Backstage for that entity. +### backstage.io/code-coverage + +```yaml +# Example: +metadata: + annotations: + backstage.io/code-coverage: scm-only +``` + +The value of this annotation controls the code-coverage backstage plugin. If set +to `scm-only`, the plugin will only take into account files stored in source +control (e.g. ignoring generated code). If set to `enabled`, all files covered +by a coverage report will be taken into account. + ## Deprecated Annotations The following annotations are deprecated, and only listed here to aid in diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 105b7e5f96..96eea3c750 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -26,14 +26,8 @@ yarn add @backstage/plugin-scaffolder ### Adding the Plugin to your `packages/app` -Add the following entry to the head of your `packages/app/src/plugins.ts`: - -```ts -export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -``` - -Next we need to install the root page that the Scaffolder plugin provides. You -can choose any path for the route, but we recommend the following: +Add the root page that the Scaffolder plugin provides to your app. You can +choose any path for the route, but we recommend the following: ```tsx import { ScaffolderPage } from '@backstage/plugin-scaffolder'; @@ -265,7 +259,7 @@ the templates at [localhost:3000/create](http://localhost:3000/create) now! Software Templates use [Cookiecutter](https://github.com/cookiecutter/cookiecutter) as templating library. By default it will use the -[spotify/backstage-cookiecutter](<[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)>) +[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile) docker image. If you are running backstage from a Docker container and you want to avoid diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index c7876f3874..aa38dd42b1 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -78,6 +78,11 @@ techdocs: # https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property endpoint: ${AWS_ENDPOINT} + # (Optional) Whether to use path style URLs when communicating with S3. + # Defaults to false. + # This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs. + s3ForcePathStyle: false + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. azureBlobStorage: diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index a96e881849..223b276d68 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -29,14 +29,8 @@ yarn add @backstage/plugin-techdocs Once the package has been installed, you need to import the plugin in your app. -Add the following to `packages/app/src/plugins.ts`: - -```typescript -export { plugin as TechDocs } from '@backstage/plugin-techdocs'; -``` - -Now we can add a route for the TechDocs page. In `packages/app/src/App.tsx`, -import TechDocsPage and add the following to `FlatRoutes`: +In `packages/app/src/App.tsx`, import `TechDocsPage` and add the following to +`FlatRoutes`: ```tsx import { TechDocsPage } from '@backstage/plugin-techdocs'; diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 2cb769b0e3..6c64e83a76 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -82,3 +82,84 @@ Caveat: Currently TechDocs sites built using URL Reader will be cached for 30 minutes which means they will not be re-built if new changes are made within 30 minutes. This cache invalidation will be replaced by commit timestamp based implementation very soon. + +## How to use a custom TechDocs home page? + +### 1st way: TechDocsCustomHome with a custom configuration + +As an example, in your main App.tsx: + +```tsx +import { + TechDocsCustomHome, + PanelType, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { Entity } from '@backstage/catalog-model'; + +const tabsConfig = [ + { + label: 'Custom Tab', + panels: [ + { + title: 'Custom Documents Cards 1', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsCardGrid' as PanelType, + // optional, is applied to a container of the panel (excludes header of panel) + panelCSS: { maxHeight: '400px', overflow:'auto' }, + filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne']; + }, + { + title: 'Custom Documents Cards 2', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsCardGrid' as PanelType, + panelCSS: { maxHeight: '400px', overflow:'auto' }, + filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo']; + }, + ], + }, + { + label: 'Overview', + panels: [ + { + title: 'Overview', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsTable' as PanelType, + filterPredicate: () => true, + }, + ], + }, +]; + +const routes = ( + + } + /> + } + /> + +``` + +An example of tabsConfig that corresponds to the default documentation home page +can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. + +Currently `panelType` has DocsCardGrid and DocsTable available. We currently +recommend that DocsCardGrid can be optionally vertically stacked by setting a +maxHeight using `panelCSS`, and DocsTable to be in a tab by itself. + +### 2nd way: Custom home page plugin + +A custom home page plugin can be built that uses the components extensions +DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a +array of documentation entities ( i.e.have a 'backstage.io/techdocs-ref' +annotation ) as an 'entities' attribute. + +For a reference to the React structure of the default home page, please refer to +`plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 23015935da..62ee4867c8 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -119,6 +119,7 @@ techdocs: Create a dedicated AWS S3 bucket for the storage of TechDocs sites. [Refer to the official documentation](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). +[Terraform example](https://github.com/backstage/backstage/blob/master/contrib/terraform/techdocs-s3-storage/terraform.tf). TechDocs will publish documentation to this bucket and will fetch files from here to serve documentation in Backstage. Note that the bucket names are @@ -155,7 +156,9 @@ variables** You should follow the [AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). -TechDocs needs access to read files and metadata of the S3 bucket. +TechDocs needs access to read files and metadata of the S3 bucket. So if you are +creating a policy for a user you want to make sure it is granted access to +ListBucket, GetObject and PutObject. If the environment variables diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index b2f3a37d90..746b4bd940 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -27,14 +27,7 @@ package.json. Backstage Apps are set up as monorepos with [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). Since CircleCI is a frontend UI plugin, it goes in `app` rather than `backend`. -2. Add the plugin itself to the App: - -```js -// packages/app/src/plugins.ts -export { plugin as CircleCi } from '@backstage/plugin-circleci'; -``` - -3. Register the plugin in the entity pages: +2. Add the `EntityCircleCIContent` extension to the entity pages in the app: ```diff // packages/app/src/components/catalog/EntityPage.tsx diff --git a/docs/integrations/datadog-rum/installation.md b/docs/integrations/datadog-rum/installation.md index 50ebe722be..f96298a33b 100644 --- a/docs/integrations/datadog-rum/installation.md +++ b/docs/integrations/datadog-rum/installation.md @@ -3,28 +3,32 @@ id: installation title: Datadog RUM Installation sidebar_label: Installation # prettier-ignore -description: Adding Datadog RUM to Your App +description: Adding Datadog Real User Monitoring (RUM) to Your App --- -There is a basic [Datadog](https://docs.datadoghq.com/real_user_monitoring/) +Datadog Real User Monitoring (RUM) allows you to visualize and analyze the +real-time performance and user journeys of your application's individual users. +This is an option to profile and monitor the user experience of your Backstage +installation. + +There is a basic [Datadog RUM](https://docs.datadoghq.com/real_user_monitoring/) integration built into Backstage. You can enable it by adding the following to -your app configuration: +your `app-config.yaml`: ```yaml app: datadogRum: clientToken: '123456789' applicationId: qwerty - # site: # datadoghq.eu default = datadoghq.com - # env: # optional + # site: datadoghq.eu + # env: 'staging' ``` -Replace the clientToken and applicationId with the ones generated for you -Datadog. +The `clientToken` and `applicationId` are generated from the Datadog RUM page +following +[these instructions](https://docs.datadoghq.com/real_user_monitoring/browser/). -optional arguments: +There are two optional arguments: -``` -site: datadoghq.eu # default equals datadoghq.com -env: dev # allow to specify the environment -``` +- `site`: The Datadog site of your organization; defaults to `datadoghq.com` +- `env`: The application environment for Datadog events (no default) diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index f2f2bc72c8..316f066614 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -67,33 +67,41 @@ is available at ### Installing plugins -Plugins are typically loaded by the UI in your Backstage applications -`plugins.ts` file. For example, -[here](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) -is that file in the Backstage sample app. +Plugins are typically installed as React components in your Backstage +application. For example, +[here](https://github.com/backstage/backstage/blob/master/packages/app/src/App.tsx) +is a file that imports many full-page plugins in the Backstage sample app. -Plugins can be enabled, and passed configuration in `apis.ts`. For example, -[here](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts) -is that file in the Backstage sample app. - -This is how the Lighthouse plugin would be enabled in a typical Backstage -application: +An example of one of these plugin components is the `CatalogIndexPage`, which is +a full-page view that allows you to browse entities in the Backstage catalog. It +is installed in the app by importing it and adding it as an element like this: ```tsx -import { ApiHolder, ApiRegistry } from '@backstage/core'; -import { - lighthouseApiRef, - LighthouseRestApi, -} from '@backstage/plugin-lighthouse'; +import { CatalogIndexPage } from '@backstage/plugin-catalog'; -const builder = ApiRegistry.builder(); +... -export const lighthouseApi = new LighthouseRestApi(/* URL of the lighthouse microservice! */); -builder.add(lighthouseApiRef, lighthouseApi); - -export default builder.build() as ApiHolder; +const routes = ( + + ... + } /> + ... + +); ``` +Note that we use `"/catalog"` as our path to this plugin page, but we can choose +any route we want for the page, as long as it doesn't collide with the routes +that we choose for the other plugins in the app. + +These components that are exported from plugins are referred to as "Plugin +Extension Components", or "Extension Components". They are regular React +components, but in addition to being able to be rendered by React, they also +contain various pieces of metadata that is used to wire together the entire app. +Extension components are created using `create*Extension` methods, which you can +read more about in the +[composability documentation](../plugins/composability.md). + As of this moment, there is no config based install procedure for plugins. Some code changes are required. diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 4172e4d1b7..6882f3d6c2 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -153,6 +153,13 @@ re-exported from @backstage/core, and this package should not be used directly. Stability: See @backstage/core +### `cost-insights` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) + +A frontend plugin that allows users to visualize, understand and optimize your +team's cloud costs. + +Stability: `1` + ### `create-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/create-app/) The CLI used to scaffold new Backstage projects. @@ -359,7 +366,7 @@ https://github.com/backstage/backstage/issues/2771. ### `tech-radar` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) -Visualize the your company's official guidelines of different areas of software +Visualize your company's official guidelines of different areas of software development. Stability: `0` diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 50c56af7a6..3e20e37cad 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -36,7 +36,7 @@ Example: ```yaml # in app-config.yaml proxy: - '/simple-example': http://simple.example.com:8080 + simple-example: http://simple.example.com:8080 '/larger-example/v1': target: http://larger.example.com:8080/svc.v1 headers: @@ -46,10 +46,11 @@ proxy: ``` Each key under the proxy configuration entry is a route to match, below the -prefix that the proxy plugin is mounted on. It must start with a slash. For -example, if the backend mounts the proxy plugin as `/proxy`, the above -configuration will lead to the proxy acting on backend requests to -`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`. +prefix that the proxy plugin is mounted on. If it does not start with a slash, +one will be prefixed automatically. For example, if the backend mounts the proxy +plugin as `/proxy`, the above configuration will lead to the proxy acting on +backend requests to `/api/proxy/simple-example/...` and +`/api/proxy/larger-example/v1/...`. The value inside each route is either a simple URL string, or an object on the format accepted by diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 2aacd02cf2..f81e3794e5 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -100,8 +100,7 @@ There are three things needed for a Backstage app to start making use of a plugin. 1. Add plugin as dependency in `app/package.json` -2. `import` plugin in `app/src/plugins.ts` -3. Import and use one or more plugin extensions, for example in +2. Import and use one or more plugin extensions, for example in `app/src/App.tsx`. Luckily these three steps happen automatically when you create a plugin with the diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 0acad832fa..cffbedca37 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -5,7 +5,7 @@ authorUrl: https://github.com/spotify category: CI/CD description: Automate your development process with CI hosted in the cloud or on a private server. documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci -iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png +iconUrl: img/circleci.png npmPackageName: '@backstage/plugin-circleci' tags: - ci diff --git a/microsite/data/plugins/cost-insights.yaml b/microsite/data/plugins/cost-insights.yaml index daa4f55a02..ec2d162b9d 100644 --- a/microsite/data/plugins/cost-insights.yaml +++ b/microsite/data/plugins/cost-insights.yaml @@ -5,7 +5,7 @@ authorUrl: https://github.com/spotify category: Discovery description: Visualize, understand and optimize your team's cloud costs. documentation: https://github.com/backstage/backstage/tree/master/plugins/cost-insights -iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png +iconUrl: img/cost-insights.png npmPackageName: '@backstage/plugin-cost-insights' tags: - web diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 1ab242ba2d..c7b049a53e 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -109,6 +109,11 @@ "integrations/bitbucket/discovery" ] }, + { + "type": "subcategory", + "label": "Datadog", + "ids": ["integrations/datadog-rum/installation"] + }, { "type": "subcategory", "label": "GitHub", @@ -130,13 +135,13 @@ }, { "type": "subcategory", - "label": "LDAP", - "ids": ["integrations/ldap/org"] + "label": "Google GCS", + "ids": ["integrations/google-cloud-storage/locations"] }, { "type": "subcategory", - "label": "Google GCS", - "ids": ["integrations/google-cloud-storage/locations"] + "label": "LDAP", + "ids": ["integrations/ldap/org"] } ], "Plugins": [ diff --git a/microsite/static/img/circleci.png b/microsite/static/img/circleci.png new file mode 100644 index 0000000000..85f262e4b3 Binary files /dev/null and b/microsite/static/img/circleci.png differ diff --git a/microsite/static/img/cost-insights.png b/microsite/static/img/cost-insights.png new file mode 100644 index 0000000000..7296dd3524 Binary files /dev/null and b/microsite/static/img/cost-insights.png differ diff --git a/mkdocs.yml b/mkdocs.yml index 031fb56059..7826b3221d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,114 +7,153 @@ plugins: nav: - Overview: - What is Backstage?: 'overview/what-is-backstage.md' - - Backstage architecture: 'overview/architecture-overview.md' - - Roadmap: 'overview/roadmap.md' + - Architecture overview: 'overview/architecture-overview.md' + - Project Roadmap: 'overview/roadmap.md' - Vision: 'overview/vision.md' - - The Spotify story: 'overview/background.md' + - The Spotify Story: 'overview/background.md' - Strategies for adopting: 'overview/adopting.md' + - Stability Index: 'overview/stability-index.md' - Logo assets: 'overview/logos.md' - - Getting started: + - Getting Started: - Getting Started: 'getting-started/index.md' + - Create an App: 'getting-started/create-an-app.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' + - App configuration: + - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' + - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' + - Keeping Backstage Updated: 'getting-started/keeping-backstage-updated.md' + - Key Concepts: 'getting-started/concepts.md' - Contributors: 'getting-started/contributors.md' - - Demo deployment: https://backstage-demo.roadie.io - - Production deployments: - - Create an App: 'getting-started/create-an-app.md' - - App configuration: - - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' - - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - - Deployment scenarios: - - Docker: 'getting-started/deployment-docker.md' - - Kubernetes: 'getting-started/deployment-k8s.md' - - Kubernetes and Helm: 'getting-started/deployment-helm.md' - - Other: 'getting-started/deployment-other.md' - - Features: + - CLI: + - Overview: 'cli/index.md' + - Commands: 'cli/commands.md' + - Core Features: - Software Catalog: - Overview: 'features/software-catalog/index.md' - - Installation: 'features/software-catalog/installation.md' - - Configuration: 'features/software-catalog/configuration.md' - - System model: 'features/software-catalog/system-model.md' + - Installing in your Backstage App: 'features/software-catalog/installation.md' + - Catalog Configuration: 'features/software-catalog/configuration.md' + - System Model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' + - Well-known Relations: 'features/software-catalog/well-known-relations.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' - API: 'features/software-catalog/api.md' - - Software creation templates: + - Kubernetes: + - Overview: 'features/kubernetes/index.md' + - Installation: 'features/kubernetes/installation.md' + - Configuration: 'features/kubernetes/configuration.md' + - Troubleshooting: 'features/kubernetes/troubleshooting.md' + - Software Templates: - Overview: 'features/software-templates/index.md' - - Installation: 'features/software-templates/installation.md' - - Adding templates: 'features/software-templates/adding-templates.md' - - Extending the Scaffolder: - - Overview: 'features/software-templates/extending/index.md' - - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Installing in your Backstage App: 'features/software-templates/installation.md' + - Adding your own Templates: 'features/software-templates/adding-templates.md' + - Writing Templates: 'features/software-templates/writing-templates.md' + - Builtin Actions: 'features/software-templates/builtin-actions.md' + - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' + - Writing Templates (Legacy): 'features/software-templates/legacy.md' - Backstage Search: - Overview: 'features/search/README.md' - - Architecture: 'features/search/architecture.md' + - Search Architecture: 'features/search/architecture.md' - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' - Concepts: 'features/techdocs/concepts.md' - TechDocs Architecture: 'features/techdocs/architecture.md' - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - - Configuration: 'features/techdocs/configuration.md' + - TechDocs Configuration Options: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' + - Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' - - Kubernetes: - - Overview: 'features/kubernetes/index.md' - Integrations: + - Overview: 'integrations/index.md' + - Azure DevOps: + - Locations: 'integrations/azure/locations.md' + - Bitbucket: + - Locations: 'integrations/bitbucket/locations.md' + - Discovery: 'integrations/bitbucket/discovery.md' + - Datadog: + - Installation: 'integrations/datadog-rum/installation.md' - GitHub: + - Locations: 'integrations/github/locations.md' + - Discovery: 'integrations/github/discovery.md' - Org Data: 'integrations/github/org.md' - - LDAP: - - Org Data: 'integrations/ldap/org.md' + - GitLab: + - Locations: 'integrations/gitlab/locations.md' - Google Analytics: - Installation: 'integrations/google-analytics/installation.md' + - Google GCS: + - Locations: 'integrations/google-cloud-storage/locations.md' + - LDAP: + - Org Data: 'integrations/ldap/org.md' - Plugins: - - Overview: 'plugins/index.md' + - Intro to plugins: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' - - Creating a new plugin: 'plugins/create-a-plugin.md' - - Developing a plugin: 'plugins/plugin-development.md' + - Create a Backstage Plugin: 'plugins/create-a-plugin.md' + - Plugin Development: 'plugins/plugin-development.md' - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Plugin Development: 'plugins/plugin-development.md' + - Integrate into the Service Catalog: 'plugins/integrating-plugin-into-service-catalog.md' - Composability System Migration: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - - Backstage backend plugin: 'plugins/backend-plugin.md' + - Backend plugin: 'plugins/backend-plugin.md' - Call existing API: 'plugins/call-existing-api.md' + - GitHub Apps for Backend Authentication: 'plugins/github-apps.md' - Testing: - - Overview: 'plugins/testing.md' + - Testing with Jest: 'plugins/testing.md' - Publishing: - - Open source and npm: 'plugins/publishing.md' - - Private/internal (non-open source): 'plugins/publish-private.md' + - Publishing: 'plugins/publishing.md' + - Publish private: 'plugins/publish-private.md' + - Add to Marketplace: 'plugins/add-to-marketplace.md' - Observability: 'plugins/observability.md' - Configuration: - - Overview: 'conf/index.md' - - Reading Configuration: 'conf/reading.md' - - Writing Configuration: 'conf/writing.md' - - Defining Configuration: 'conf/defining.md' + - Static Configuration in Backstage: 'conf/index.md' + - Reading Backstage Configuration: 'conf/reading.md' + - Writing Backstage Configuration: 'conf/writing.md' + - Defining Configuration for your Plugin: 'conf/defining.md' - Authentication and identity: - - Overview: 'auth/index.md' - - Add auth provider: 'auth/add-auth-provider.md' + - Adding Authentication: 'auth/index.md' + - Included providers: + - Auth0: 'auth/auth0/provider.md' + - Azure: 'auth/microsoft/provider.md' + - GitHub: 'auth/github/provider.md' + - GitLab: 'auth/gitlab/provider.md' + - Google: 'auth/google/provider.md' + - Okta: 'auth/okta/provider.md' + - OneLogin: 'auth/onelogin/provider.md' + - Adding authentication providers: 'auth/add-auth-provider.md' + - Using authentication and identity: 'auth/using-auth.md' - Auth backend: 'auth/auth-backend.md' - - Auth backend class structure: 'auth/auth-backend-classes.md' - - OAuth: 'auth/oauth.md' + - OAuth and OpenID Connect: 'auth/oauth.md' + - Auth backend classes: 'auth/auth-backend-classes.md' - Glossary: 'auth/glossary.md' + - Deployment: + - Deploying Backstage: 'deployment/index.md' + - Docker: 'deployment/docker.md' + - Kubernetes: 'deployment/k8s.md' + - Helm: 'deployment/helm.md' + - Heroku: 'deployment/heroku.md' - Designing for Backstage: - - Backstage Design Language System (DLS): 'dls/design.md' - - Storybook -- reusable UI components: 'http://backstage.io/storybook' + - Design: 'dls/design.md' - Contributing to Storybook: 'dls/contributing-to-storybook.md' - - Figma resources: 'dls/figma.md' + - Figma: 'dls/figma.md' - API references: - - TypeScript APIs: - - Utilities: 'api/utility-apis.md' + - TypeScript API: + - Utility APIs: 'api/utility-apis.md' + - reference/utility-apis/README: 'reference/utility-apis/README.md' - createPlugin: 'reference/createPlugin.md' - - createPlugin-feature-flags: 'reference/createPlugin-feature-flags.md' + - createPlugin -feature flags: 'reference/createPlugin-feature-flags.md' - Backend APIs: - Backend: 'api/backend.md' - Tutorials: - - Overview: 'tutorials/index.md' + - Future developer journey: 'tutorials/journey.md' + - Monorepo App Setup With Authentication: 'tutorials/quickstart-app-auth.md' + - Adding Custom Plugin to Existing Monorepo App: 'tutorials/quickstart-app-plugin.md' + - Switching Backstage from SQLite to PostgreSQL: 'tutorials/switching-sqlite-postgres.md' - Architecture Decision Records (ADRs): - Overview: 'architecture-decisions/index.md' - ADR001 - Architecture Decision Record (ADR) log: 'architecture-decisions/adr001-add-adr-log.md' @@ -129,7 +168,7 @@ nav: - ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md' - ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md' - Support: - - 'support/support.md' - - 'support/project-structure.md' + - Support and community: 'support/support.md' + - Backstage Project Structure: 'support/project-structure.md' - Glossary: glossary.md - FAQ: FAQ.md diff --git a/package.json b/package.json index d87b98fbdb..b0f79031de 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "start": "yarn workspace example-app start", "start-backend": "yarn workspace example-backend start", "build": "lerna run build", + "build:api-reports": "tsc && yarn build:api-reports:only", + "build:api-reports:only": "ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts", + "build:api-docs": "yarn build:api-reports --docs", "tsc": "tsc", "tsc:full": "tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", @@ -39,10 +42,16 @@ "**/@roadiehq/**/@backstage/core": "*", "**/@roadiehq/**/@backstage/plugin-catalog": "*", "**/@roadiehq/**/@backstage/catalog-model": "*", + "**/@microsoft/api-extractor/typescript": "^4.0.3", "graphql-language-service-interface": "2.8.2", "graphql-language-service-parser": "1.9.0" }, "version": "1.0.0", + "dependencies": { + "@microsoft/api-extractor": "7.13.2-pr1916.0", + "@microsoft/api-documenter": "^7.12.16", + "@microsoft/api-extractor-model": "^7.12.5" + }, "devDependencies": { "@changesets/cli": "^2.14.0", "@octokit/openapi-types": "^2.2.0", diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 81a7f66f4c..e9d3e63ad0 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,78 @@ # example-app +## 0.2.26 + +### Patch Changes + +- Updated dependencies [1ce80ff02] +- Updated dependencies [b98de52ae] +- Updated dependencies [4c42ecca2] +- Updated dependencies [c614ede9a] +- Updated dependencies [9afcac5af] +- Updated dependencies [07a7806c3] +- Updated dependencies [f6efa71ee] +- Updated dependencies [19a4dd710] +- Updated dependencies [dcd54c7cd] +- Updated dependencies [da546ce00] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6fbd7beca] +- Updated dependencies [15cbe6815] +- Updated dependencies [39bdaa004] +- Updated dependencies [cb8c848a3] +- Updated dependencies [21fddf452] +- Updated dependencies [17915e29b] +- Updated dependencies [6eaecbd81] +- Updated dependencies [23769512a] +- Updated dependencies [1a142ae8a] + - @backstage/plugin-api-docs@0.4.12 + - @backstage/plugin-cost-insights@0.8.5 + - @backstage/plugin-github-actions@0.4.4 + - @backstage/plugin-catalog-import@0.5.4 + - @backstage/plugin-circleci@0.2.13 + - @backstage/plugin-explore@0.3.4 + - @backstage/plugin-graphiql@0.2.10 + - @backstage/plugin-jenkins@0.4.2 + - @backstage/plugin-lighthouse@0.2.15 + - @backstage/plugin-pagerduty@0.3.3 + - @backstage/plugin-rollbar@0.3.4 + - @backstage/plugin-sentry@0.3.9 + - @backstage/core@0.7.7 + - @backstage/plugin-scaffolder@0.9.2 + - @backstage/plugin-catalog@0.5.6 + - @backstage/plugin-search@0.3.5 + - @backstage/plugin-techdocs@0.9.0 + +## 0.2.25 + +### Patch Changes + +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [4e5c94249] +- Updated dependencies [99fbef232] +- Updated dependencies [cb0206b2b] +- Updated dependencies [1373f4f12] +- Updated dependencies [29a7e4be8] +- Updated dependencies [ab07d77f6] +- Updated dependencies [96728a2af] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [87c4f59de] +- Updated dependencies [55b2fc0c0] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] +- Updated dependencies [ac6025f63] +- Updated dependencies [e292e393f] +- Updated dependencies [479b29124] + - @backstage/core@0.7.6 + - @backstage/cli@0.6.9 + - @backstage/plugin-scaffolder@0.9.1 + - @backstage/plugin-catalog-import@0.5.3 + - @backstage/plugin-catalog@0.5.5 + - @backstage/theme@0.2.6 + - @backstage/plugin-code-coverage@0.1.2 + - @backstage/plugin-techdocs@0.8.0 + ## 0.2.24 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index f052d27109..74cabfcaf3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,42 +1,43 @@ { "name": "example-app", - "version": "0.2.24", + "version": "0.2.26", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.7", - "@backstage/cli": "^0.6.8", - "@backstage/core": "^0.7.5", + "@backstage/cli": "^0.6.9", + "@backstage/core": "^0.7.7", "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-api-docs": "^0.4.11", + "@backstage/plugin-api-docs": "^0.4.12", "@backstage/plugin-badges": "^0.2.0", - "@backstage/plugin-catalog": "^0.5.4", - "@backstage/plugin-catalog-import": "^0.5.2", + "@backstage/plugin-catalog": "^0.5.6", + "@backstage/plugin-catalog-import": "^0.5.4", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/plugin-circleci": "^0.2.12", + "@backstage/plugin-circleci": "^0.2.13", "@backstage/plugin-cloudbuild": "^0.2.13", - "@backstage/plugin-cost-insights": "^0.8.4", - "@backstage/plugin-explore": "^0.3.2", + "@backstage/plugin-code-coverage": "^0.1.2", + "@backstage/plugin-cost-insights": "^0.8.5", + "@backstage/plugin-explore": "^0.3.4", "@backstage/plugin-gcp-projects": "^0.2.5", - "@backstage/plugin-github-actions": "^0.4.2", - "@backstage/plugin-graphiql": "^0.2.9", - "@backstage/plugin-jenkins": "^0.4.1", + "@backstage/plugin-github-actions": "^0.4.4", + "@backstage/plugin-graphiql": "^0.2.10", + "@backstage/plugin-jenkins": "^0.4.2", "@backstage/plugin-kafka": "^0.2.6", "@backstage/plugin-kubernetes": "^0.4.2", - "@backstage/plugin-lighthouse": "^0.2.14", + "@backstage/plugin-lighthouse": "^0.2.15", "@backstage/plugin-newrelic": "^0.2.6", "@backstage/plugin-org": "^0.3.12", - "@backstage/plugin-pagerduty": "0.3.2", - "@backstage/plugin-rollbar": "^0.3.3", - "@backstage/plugin-scaffolder": "^0.9.0", - "@backstage/plugin-search": "^0.3.4", - "@backstage/plugin-sentry": "^0.3.8", + "@backstage/plugin-pagerduty": "0.3.3", + "@backstage/plugin-rollbar": "^0.3.4", + "@backstage/plugin-scaffolder": "^0.9.2", + "@backstage/plugin-search": "^0.3.5", + "@backstage/plugin-sentry": "^0.3.9", "@backstage/plugin-shortcuts": "^0.1.1", "@backstage/plugin-tech-radar": "^0.3.9", - "@backstage/plugin-techdocs": "^0.7.2", + "@backstage/plugin-techdocs": "^0.9.0", "@backstage/plugin-todo": "^0.1.0", "@backstage/plugin-user-settings": "^0.2.8", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.12", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index d4db2b0e63..601134362d 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -102,6 +102,7 @@ import { EntityTravisCIOverviewCard, isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; +import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -303,6 +304,10 @@ const serviceEntityPage = ( + + + + @@ -347,6 +352,10 @@ const websiteEntityPage = ( + + + + diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index c194f7e666..68fab7d861 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/backend-common +## 0.6.3 + +### Patch Changes + +- d367f63b5: remove use of deprecated type HelmetOptions +- b42531cfe: Support configuration of file storage for SQLite databases. Every plugin has its + own database file at the specified path. + ## 0.6.2 ### Patch Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md new file mode 100644 index 0000000000..58b9aa36d9 --- /dev/null +++ b/packages/backend-common/api-report.md @@ -0,0 +1,341 @@ +## API Report File for "@backstage/backend-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegration } from '@backstage/integration'; +import { BitbucketIntegration } from '@backstage/integration'; +import { Config } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; +import cors from 'cors'; +import Docker from 'dockerode'; +import { ErrorRequestHandler } from 'express'; +import express from 'express'; +import { Format } from 'logform'; +import { GithubCredentialsProvider } from '@backstage/integration'; +import { GitHubIntegration } from '@backstage/integration'; +import { GitLabIntegration } from '@backstage/integration'; +import * as http from 'http'; +import { Knex } from 'knex'; +import { Logger } from 'winston'; +import { MergeResult } from 'isomorphic-git'; +import { PushResult } from 'isomorphic-git'; +import { Readable } from 'stream'; +import { ReadCommitResult } from 'isomorphic-git'; +import { RequestHandler } from 'express'; +import { Router } from 'express'; +import { Server } from 'http'; +import * as winston from 'winston'; +import { Writable } from 'stream'; + +// @public (undocumented) +export class AzureUrlReader implements UrlReader { + constructor(integration: AzureIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public +export class BitbucketUrlReader implements UrlReader { + constructor(integration: BitbucketIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +export const coloredFormat: Format; + +// @public @deprecated +export const createDatabase: typeof createDatabaseClient; + +// @public +export function createDatabaseClient(dbConfig: Config, overrides?: Partial): Knex; + +// @public (undocumented) +export function createRootLogger(options?: winston.LoggerOptions, env?: NodeJS.ProcessEnv): winston.Logger; + +// @public +export function createServiceBuilder(_module: NodeModule): ServiceBuilderImpl; + +// @public (undocumented) +export function createStatusCheckRouter(options: StatusCheckRouterOptions): Promise; + +// @public +export function ensureDatabaseExists(dbConfig: Config, ...databases: Array): Promise; + +// @public +export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler; + +// @public (undocumented) +export type ErrorHandlerOptions = { + showStackTraces?: boolean; + logger?: Logger; + logClientErrors?: boolean; +}; + +// @public (undocumented) +export function getRootLogger(): winston.Logger; + +// @public +export function getVoidLogger(): winston.Logger; + +// @public (undocumented) +export class Git { + // (undocumented) + add({ dir, filepath, }: { + dir: string; + filepath: string; + }): Promise; + // (undocumented) + addRemote({ dir, url, remote, }: { + dir: string; + remote: string; + url: string; + }): Promise; + // (undocumented) + clone({ url, dir, ref, }: { + url: string; + dir: string; + ref?: string; + }): Promise; + // (undocumented) + commit({ dir, message, author, committer, }: { + dir: string; + message: string; + author: { + name: string; + email: string; + }; + committer: { + name: string; + email: string; + }; + }): Promise; + // (undocumented) + currentBranch({ dir, fullName, }: { + dir: string; + fullName?: boolean; + }): Promise; + // (undocumented) + fetch({ dir, remote, }: { + dir: string; + remote?: string; + }): Promise; + // (undocumented) + static fromAuth: ({ username, password, logger, }: { + username?: string | undefined; + password?: string | undefined; + logger?: Logger | undefined; + }) => Git; + // (undocumented) + init({ dir }: { + dir: string; + }): Promise; + // (undocumented) + merge({ dir, theirs, ours, author, committer, }: { + dir: string; + theirs: string; + ours?: string; + author: { + name: string; + email: string; + }; + committer: { + name: string; + email: string; + }; + }): Promise; + // (undocumented) + push({ dir, remote }: { + dir: string; + remote: string; + }): Promise; + // (undocumented) + readCommit({ dir, sha, }: { + dir: string; + sha: string; + }): Promise; + // (undocumented) + resolveRef({ dir, ref, }: { + dir: string; + ref: string; + }): Promise; +} + +// @public +export class GithubUrlReader implements UrlReader { + constructor(integration: GitHubIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: GithubCredentialsProvider; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +export class GitlabUrlReader implements UrlReader { + constructor(integration: GitLabIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// @public +export function loadBackendConfig(options: Options): Promise; + +// @public +export function notFoundHandler(): RequestHandler; + +// @public +export interface PluginDatabaseManager { + getClient(): Promise; +} + +// @public +export type PluginEndpointDiscovery = { + getBaseUrl(pluginId: string): Promise; + getExternalBaseUrl(pluginId: string): Promise; +}; + +// @public (undocumented) +export type ReadTreeResponse = { + files(): Promise; + archive(): Promise; + dir(options?: ReadTreeResponseDirOptions): Promise; + etag: string; +}; + +// @public +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +// @public +export function requestLoggingHandler(logger?: Logger): RequestHandler; + +// @public +export function resolvePackagePath(name: string, ...paths: string[]): string; + +// @public (undocumented) +export const runDockerContainer: ({ imageName, args, logStream, dockerClient, mountDirs, workingDir, envVars, createOptions, }: RunDockerContainerOptions) => Promise<{ + error: any; + statusCode: any; +}>; + +// @public +export type SearchResponse = { + files: SearchResponseFile[]; + etag: string; +}; + +// @public +export type SearchResponseFile = { + url: string; + content(): Promise; +}; + +// @public (undocumented) +export type ServiceBuilder = { + loadConfig(config: ConfigReader): ServiceBuilder; + setPort(port: number): ServiceBuilder; + setHost(host: string): ServiceBuilder; + setLogger(logger: Logger): ServiceBuilder; + enableCors(options: cors.CorsOptions): ServiceBuilder; + setHttpsSettings(settings: HttpsSettings): ServiceBuilder; + addRouter(root: string, router: Router | RequestHandler): ServiceBuilder; + start(): Promise; +}; + +// @public (undocumented) +export function setRootLogger(newLogger: winston.Logger): void; + +// @public +export class SingleConnectionDatabaseManager { + forPlugin(pluginId: string): PluginDatabaseManager; + static fromConfig(config: Config): SingleConnectionDatabaseManager; + } + +// @public +export class SingleHostDiscovery implements PluginEndpointDiscovery { + static fromConfig(config: Config, options?: { + basePath?: string; + }): SingleHostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + // (undocumented) + getExternalBaseUrl(pluginId: string): Promise; + } + +// @public (undocumented) +export type StatusCheck = () => Promise; + +// @public +export function statusCheckHandler(options?: StatusCheckHandlerOptions): Promise; + +// @public (undocumented) +export interface StatusCheckHandlerOptions { + statusCheck?: StatusCheck; +} + +// @public +export type UrlReader = { + read(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; + search(url: string, options?: SearchOptions): Promise; +}; + +// @public +export class UrlReaders { + static create({ logger, config, factories }: CreateOptions): UrlReader; + static default({ logger, config, factories }: CreateOptions): UrlReader; +} + +// @public +export function useHotCleanup(_module: NodeModule, cancelEffect: () => void): void; + +// @public +export function useHotMemoize(_module: NodeModule, valueFactory: () => T): T; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 41fa657818..998ac475d9 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.6.2", + "version": "0.6.3", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -73,7 +73,7 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 07da975184..c17545779a 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -18,7 +18,7 @@ import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; -import helmet, { HelmetOptions } from 'helmet'; +import helmet from 'helmet'; import * as http from 'http'; import stoppable from 'stoppable'; import { Logger } from 'winston'; @@ -195,14 +195,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { }); } - private getOptions(): { - port: number; - host: string; - logger: Logger; - corsOptions?: cors.CorsOptions; - httpsSettings?: HttpsSettings; - helmetOptions: HelmetOptions; - } { + private getOptions() { return { port: this.port ?? DEFAULT_PORT, host: this.host ?? DEFAULT_HOST, diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 6585ea2723..ee04e2bb6b 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,29 @@ # example-backend +## 0.2.25 + +### Patch Changes + +- Updated dependencies [b9b2b4b76] +- Updated dependencies [84c54474d] +- Updated dependencies [49574a8a3] +- Updated dependencies [d367f63b5] +- Updated dependencies [5fe62f124] +- Updated dependencies [09b5fcf2e] +- Updated dependencies [55b2fc0c0] +- Updated dependencies [c42cd1daa] +- Updated dependencies [b42531cfe] +- Updated dependencies [c2306f898] + - @backstage/plugin-search-backend@0.1.3 + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/plugin-scaffolder-backend@0.10.0 + - @backstage/plugin-rollbar-backend@0.1.9 + - @backstage/backend-common@0.6.3 + - @backstage/plugin-catalog-backend@0.8.0 + - @backstage/plugin-code-coverage-backend@0.1.2 + - @backstage/plugin-kubernetes-backend@0.3.5 + - example-app@0.2.25 + ## 0.2.22 ### Patch Changes diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 02aab45609..31231a3a4a 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -22,7 +22,7 @@ RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" # Then copy the rest of the backend bundle, along with any other files we might want. -ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ +COPY packages/backend/dist/bundle.tar.gz app-config.yaml ./ RUN tar xzf bundle.tar.gz && rm bundle.tar.gz CMD ["node", "packages/backend", "--config", "app-config.yaml"] diff --git a/packages/backend/package.json b/packages/backend/package.json index 40e5a1cd2f..440ceca8e5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.22", + "version": "0.2.25", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,29 +27,30 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-client": "^0.3.9", "@backstage/catalog-model": "^0.7.5", "@backstage/config": "^0.1.4", "@backstage/plugin-app-backend": "^0.3.10", "@backstage/plugin-auth-backend": "^0.3.7", "@backstage/plugin-badges-backend": "^0.1.1", - "@backstage/plugin-catalog-backend": "^0.7.0", + "@backstage/plugin-catalog-backend": "^0.8.0", + "@backstage/plugin-code-coverage-backend": "^0.1.2", "@backstage/plugin-graphql-backend": "^0.1.6", - "@backstage/plugin-kubernetes-backend": "^0.3.3", + "@backstage/plugin-kubernetes-backend": "^0.3.5", "@backstage/plugin-kafka-backend": "^0.2.3", "@backstage/plugin-proxy-backend": "^0.2.6", - "@backstage/plugin-rollbar-backend": "^0.1.8", - "@backstage/plugin-scaffolder-backend": "^0.9.4", - "@backstage/plugin-search-backend": "^0.1.2", - "@backstage/plugin-search-backend-node": "^0.1.2", + "@backstage/plugin-rollbar-backend": "^0.1.9", + "@backstage/plugin-scaffolder-backend": "^0.10.0", + "@backstage/plugin-search-backend": "^0.1.3", + "@backstage/plugin-search-backend-node": "^0.1.3", "@backstage/plugin-techdocs-backend": "^0.7.0", "@backstage/plugin-todo-backend": "^0.1.3", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.21", + "example-app": "^0.2.25", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^0.95.1", @@ -59,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.9", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index cbbdaecf5e..00ed248df5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -37,6 +37,7 @@ import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; +import codeCoverage from './plugins/codecoverage'; import kubernetes from './plugins/kubernetes'; import kafka from './plugins/kafka'; import rollbar from './plugins/rollbar'; @@ -75,6 +76,9 @@ async function main() { const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); + const codeCoverageEnv = useHotMemoize(module, () => + createEnv('code-coverage'), + ); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); @@ -90,6 +94,7 @@ async function main() { const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/code-coverage', await codeCoverage(codeCoverageEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); apiRouter.use('/auth', await auth(authEnv)); diff --git a/packages/backend/src/plugins/codecoverage.ts b/packages/backend/src/plugins/codecoverage.ts new file mode 100644 index 0000000000..c06e0e516f --- /dev/null +++ b/packages/backend/src/plugins/codecoverage.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouter } from '@backstage/plugin-code-coverage-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin(env: PluginEnvironment) { + return await createRouter({ + config: env.config, + discovery: env.discovery, + database: env.database, + urlReader: env.reader, + logger: env.logger, + }); +} diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 7bd473756d..1691f53485 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -15,7 +15,10 @@ */ import { useHotCleanup } from '@backstage/backend-common'; import { createRouter } from '@backstage/plugin-search-backend'; -import { IndexBuilder } from '@backstage/plugin-search-backend-node'; +import { + IndexBuilder, + LunrSearchEngine, +} from '@backstage/plugin-search-backend-node'; import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; @@ -23,21 +26,22 @@ export default async function createPlugin({ logger, discovery, }: PluginEnvironment) { - const indexBuilder = new IndexBuilder({ logger }); + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ type: 'software-catalog', defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator(discovery), + collator: new DefaultCatalogCollator({ discovery }), }); - // TODO: Move refresh loop logic into the builder. - const timerId = setInterval(() => { - indexBuilder.build(); - }, 60000); - useHotCleanup(module, () => clearInterval(timerId)); + const { scheduler } = await indexBuilder.build(); + + scheduler.start(); + useHotCleanup(module, () => scheduler.stop()); return await createRouter({ + engine: indexBuilder.getSearchEngine(), logger, }); } diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md new file mode 100644 index 0000000000..4b489ff2e4 --- /dev/null +++ b/packages/catalog-client/api-report.md @@ -0,0 +1,82 @@ +## API Report File for "@backstage/catalog-client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import { Location as Location_2 } from '@backstage/catalog-model'; + +// @public (undocumented) +export type AddLocationRequest = { + type?: string; + target: string; + dryRun?: boolean; + presence?: 'optional' | 'required'; +}; + +// @public (undocumented) +export type AddLocationResponse = { + location: Location_2; + entities: Entity[]; +}; + +// @public (undocumented) +export interface CatalogApi { + // (undocumented) + addLocation(location: AddLocationRequest, options?: CatalogRequestOptions): Promise; + // (undocumented) + getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; + // (undocumented) + getEntityByName(name: EntityName, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationById(id: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeEntityByUid(uid: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeLocationById(id: string, options?: CatalogRequestOptions): Promise; +} + +// @public (undocumented) +export class CatalogClient implements CatalogApi { + constructor(options: { + discoveryApi: DiscoveryApi; + }); + // (undocumented) + addLocation({ type, target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions): Promise; + // (undocumented) + getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; + // (undocumented) + getEntityByName(compoundName: EntityName, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationById(id: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeEntityByUid(uid: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeLocationById(id: string, options?: CatalogRequestOptions): Promise; + } + +// @public (undocumented) +export type CatalogEntitiesRequest = { + filter?: Record[] | Record | undefined; + fields?: string[] | undefined; +}; + +// @public (undocumented) +export type CatalogListResponse = { + items: T[]; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 19da45e4ec..359e3a2c60 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -76,7 +76,38 @@ describe('CatalogClient', () => { expect(response).toEqual(defaultResponse); }); - it('builds entity search filters properly', async () => { + it('builds multiple entity search filters properly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => { + expect(req.url.search).toBe( + '?filter=a=1,b=2,b=3,%C3%B6=%3D&filter=a=2', + ); + return res(ctx.json([])); + }), + ); + + const response = await client.getEntities( + { + filter: [ + { + a: '1', + b: ['2', '3'], + ö: '=', + }, + { + a: '2', + }, + ], + }, + { token }, + ); + + expect(response.items).toEqual([]); + }); + + it('builds single entity search filter properly', async () => { expect.assertions(2); server.use( diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 3de25e9808..a246b3abab 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -56,18 +56,27 @@ export class CatalogClient implements CatalogApi { request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise> { - const { filter = {}, fields = [] } = request ?? {}; + const { filter = [], fields = [] } = request ?? {}; + const filterItems = [filter].flat(); const params: string[] = []; - const filterParts: string[] = []; - for (const [key, value] of Object.entries(filter)) { - for (const v of [value].flat()) { - filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`); + // filter param can occur multiple times, for example + // /api/catalog/entities?filter=metadata.name=wayback-search,kind=component&filter=metadata.name=www-artist,kind=component' + // the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters + // the "inner array" defined within a `filter` param corresponds to "allOf" filters + for (const filterItem of filterItems) { + const filterParts: string[] = []; + for (const [key, value] of Object.entries(filterItem)) { + for (const v of [value].flat()) { + filterParts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(v)}`, + ); + } } - } - if (filterParts.length) { - params.push(`filter=${filterParts.join(',')}`); + if (filterParts.length) { + params.push(`filter=${filterParts.join(',')}`); + } } if (fields.length) { diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types.ts index 0d25bf7483..ef907eafa9 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types.ts @@ -17,7 +17,10 @@ import { Entity, EntityName, Location } from '@backstage/catalog-model'; export type CatalogEntitiesRequest = { - filter?: Record | undefined; + filter?: + | Record[] + | Record + | undefined; fields?: string[] | undefined; }; diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md new file mode 100644 index 0000000000..c37c86b01f --- /dev/null +++ b/packages/catalog-model/api-report.md @@ -0,0 +1,571 @@ +## API Report File for "@backstage/catalog-model" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonObject } from '@backstage/config'; +import { JSONSchema7 } from 'json-schema'; +import { JsonValue } from '@backstage/config'; +import * as yup from 'yup'; + +// @public (undocumented) +export const analyzeLocationSchema: yup.ObjectSchema<{ + location: LocationSpec; +}, object>; + +// @public (undocumented) +interface ApiEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION[number]; + // (undocumented) + kind: typeof KIND; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + definition: string; + system?: string; + }; +} + +export { ApiEntityV1alpha1 as ApiEntity } + +export { ApiEntityV1alpha1 } + +// @public (undocumented) +export const apiEntityV1alpha1Validator: KindValidator; + +// @public +export class CommonValidatorFunctions { + static isJsonSafe(value: unknown): boolean; + static isValidDnsLabel(value: unknown): boolean; + static isValidDnsSubdomain(value: unknown): boolean; + static isValidPrefixAndOrSuffix(value: unknown, separator: string, isValidPrefix: (value: string) => boolean, isValidSuffix: (value: string) => boolean): boolean; + static isValidString(value: unknown): boolean; + static isValidUrl(value: unknown): boolean; +} + +// @public +export function compareEntityToRef(entity: Entity, ref: EntityRef | EntityName, context?: EntityRefContext): boolean; + +// @public (undocumented) +interface ComponentEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_2[number]; + // (undocumented) + kind: typeof KIND_2; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + subcomponentOf?: string; + providesApis?: string[]; + consumesApis?: string[]; + dependsOn?: string[]; + system?: string; + }; +} + +export { ComponentEntityV1alpha1 as ComponentEntity } + +export { ComponentEntityV1alpha1 } + +// @public (undocumented) +export const componentEntityV1alpha1Validator: KindValidator; + +// @public +export class DefaultNamespaceEntityPolicy implements EntityPolicy { + constructor(namespace?: string); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public (undocumented) +interface DomainEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_3[number]; + // (undocumented) + kind: typeof KIND_3; + // (undocumented) + spec: { + owner: string; + }; +} + +export { DomainEntityV1alpha1 as DomainEntity } + +export { DomainEntityV1alpha1 } + +// @public (undocumented) +export const domainEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export const EDIT_URL_ANNOTATION = "backstage.io/edit-url"; + +// @public +export type Entity = { + apiVersion: string; + kind: string; + metadata: EntityMeta; + spec?: JsonObject; + relations?: EntityRelation[]; +}; + +// @public +export const ENTITY_DEFAULT_NAMESPACE = "default"; + +// @public +export const ENTITY_META_GENERATED_FIELDS: readonly ["uid", "etag", "generation"]; + +// @public +export function entityHasChanges(previous: Entity, next: Entity): boolean; + +// @public +export type EntityLink = { + url: string; + title?: string; + icon?: string; +}; + +// @public +export type EntityMeta = JsonObject & { + uid?: string; + etag?: string; + generation?: number; + name: string; + namespace?: string; + description?: string; + labels?: Record; + annotations?: Record; + tags?: string[]; + links?: EntityLink[]; +}; + +// @public +export type EntityName = { + kind: string; + namespace: string; + name: string; +}; + +// @public (undocumented) +export const EntityPolicies: { + allOf(policies: EntityPolicy[]): AllEntityPolicies; + oneOf(policies: EntityPolicy[]): AnyEntityPolicy; +}; + +// @public +export type EntityPolicy = { + enforce(entity: Entity): Promise; +}; + +// @public +export type EntityRef = string | { + kind?: string; + namespace?: string; + name: string; +}; + +// @public +export type EntityRelation = { + type: string; + target: EntityName; +}; + +// @public +export type EntityRelationSpec = { + source: EntityName; + type: string; + target: EntityName; +}; + +// @public +export class FieldFormatEntityPolicy implements EntityPolicy { + constructor(validators?: Validators); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public +export function generateEntityEtag(): string; + +// @public +export function generateEntityUid(): string; + +// @public +export function generateUpdatedEntity(previous: Entity, next: Entity): Entity; + +// @public +export function getEntityName(entity: Entity): EntityName; + +// @public +export function getEntitySourceLocation(entity: Entity): { + type: string; + target: string; +}; + +// @public (undocumented) +interface GroupEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_4[number]; + // (undocumented) + kind: typeof KIND_4; + // (undocumented) + spec: { + type: string; + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + parent?: string; + children: string[]; + members?: string[]; + }; +} + +export { GroupEntityV1alpha1 as GroupEntity } + +export { GroupEntityV1alpha1 } + +// @public (undocumented) +export const groupEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export type JSONSchema = JSONSchema7 & { + [key in string]?: JsonValue; +}; + +// @public +export type KindValidator = { + check(entity: Entity): Promise; +}; + +// @public +export class KubernetesValidatorFunctions { + // (undocumented) + static isValidAnnotationKey(value: unknown): boolean; + // (undocumented) + static isValidAnnotationValue(value: unknown): boolean; + // (undocumented) + static isValidApiVersion(value: unknown): boolean; + // (undocumented) + static isValidKind(value: unknown): boolean; + // (undocumented) + static isValidLabelKey(value: unknown): boolean; + // (undocumented) + static isValidLabelValue(value: unknown): boolean; + // (undocumented) + static isValidNamespace(value: unknown): boolean; + // (undocumented) + static isValidObjectName(value: unknown): boolean; +} + +// @public (undocumented) +type Location_2 = { + id: string; +} & LocationSpec; + +export { Location_2 as Location } + +// @public (undocumented) +export const LOCATION_ANNOTATION = "backstage.io/managed-by-location"; + +// @public (undocumented) +interface LocationEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_5[number]; + // (undocumented) + kind: typeof KIND_5; + // (undocumented) + spec: { + type?: string; + target?: string; + targets?: string[]; + }; +} + +export { LocationEntityV1alpha1 as LocationEntity } + +export { LocationEntityV1alpha1 } + +// @public (undocumented) +export const locationEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export const locationSchema: yup.ObjectSchema; + +// @public (undocumented) +export type LocationSpec = { + type: string; + target: string; + presence?: 'optional' | 'required'; +}; + +// @public (undocumented) +export const locationSpecSchema: yup.ObjectSchema; + +// @public (undocumented) +export function makeValidator(overrides?: Partial): Validators; + +// @public +export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { + constructor(knownFields?: string[]); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public (undocumented) +export const ORIGIN_LOCATION_ANNOTATION = "backstage.io/managed-by-origin-location"; + +// @public +export function parseEntityName(ref: EntityRef, context?: EntityRefContext): EntityName; + +// @public +export function parseEntityRef(ref: EntityRef, context?: { + defaultKind: string; + defaultNamespace: string; +}): { + kind: string; + namespace: string; + name: string; +}; + +// @public (undocumented) +export function parseEntityRef(ref: EntityRef, context?: { + defaultKind: string; +}): { + kind: string; + namespace?: string; + name: string; +}; + +// @public (undocumented) +export function parseEntityRef(ref: EntityRef, context?: { + defaultNamespace: string; +}): { + kind?: string; + namespace: string; + name: string; +}; + +// @public +export function parseLocationReference(ref: string): { + type: string; + target: string; +}; + +// @public (undocumented) +export const RELATION_API_CONSUMED_BY = "apiConsumedBy"; + +// @public (undocumented) +export const RELATION_API_PROVIDED_BY = "apiProvidedBy"; + +// @public (undocumented) +export const RELATION_CHILD_OF = "childOf"; + +// @public +export const RELATION_CONSUMES_API = "consumesApi"; + +// @public (undocumented) +export const RELATION_DEPENDENCY_OF = "dependencyOf"; + +// @public +export const RELATION_DEPENDS_ON = "dependsOn"; + +// @public (undocumented) +export const RELATION_HAS_MEMBER = "hasMember"; + +// @public (undocumented) +export const RELATION_HAS_PART = "hasPart"; + +// @public +export const RELATION_MEMBER_OF = "memberOf"; + +// @public +export const RELATION_OWNED_BY = "ownedBy"; + +// @public (undocumented) +export const RELATION_OWNER_OF = "ownerOf"; + +// @public +export const RELATION_PARENT_OF = "parentOf"; + +// @public +export const RELATION_PART_OF = "partOf"; + +// @public (undocumented) +export const RELATION_PROVIDES_API = "providesApi"; + +// @public (undocumented) +interface ResourceEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_6[number]; + // (undocumented) + kind: typeof KIND_6; + // (undocumented) + spec: { + type: string; + owner: string; + dependsOn?: string[]; + system?: string; + }; +} + +export { ResourceEntityV1alpha1 as ResourceEntity } + +export { ResourceEntityV1alpha1 } + +// @public (undocumented) +export const resourceEntityV1alpha1Validator: KindValidator; + +// @public @deprecated (undocumented) +export function schemaValidator(kind: string, apiVersion: readonly string[], schema: yup.Schema): KindValidator; + +// @public +export class SchemaValidEntityPolicy implements EntityPolicy { + // (undocumented) + enforce(entity: Entity): Promise; + } + +// @public @deprecated +export function serializeEntityRef(ref: Entity | { + kind?: string; + namespace?: string; + name: string; +}): EntityRef; + +// @public (undocumented) +export const SOURCE_LOCATION_ANNOTATION = "backstage.io/source-location"; + +// @public +export function stringifyEntityRef(ref: Entity | { + kind: string; + namespace?: string; + name: string; +}): string; + +// @public +export function stringifyLocationReference(ref: { + type: string; + target: string; +}): string; + +// @public (undocumented) +interface SystemEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_7[number]; + // (undocumented) + kind: typeof KIND_7; + // (undocumented) + spec: { + owner: string; + domain?: string; + }; +} + +export { SystemEntityV1alpha1 as SystemEntity } + +export { SystemEntityV1alpha1 } + +// @public (undocumented) +export const systemEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +interface TemplateEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_8[number]; + // (undocumented) + kind: typeof KIND_8; + // (undocumented) + spec: { + type: string; + templater: string; + path?: string; + schema: JSONSchema; + }; +} + +export { TemplateEntityV1alpha1 as TemplateEntity } + +export { TemplateEntityV1alpha1 } + +// @public (undocumented) +export const templateEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export interface TemplateEntityV1beta2 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_9[number]; + // (undocumented) + kind: typeof KIND_9; + // (undocumented) + metadata: EntityMeta & { + title?: string; + }; + // (undocumented) + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id?: string; + name?: string; + action: string; + parameters?: JsonObject; + }>; + output?: { + [name: string]: string; + }; + }; +} + +// @public (undocumented) +export const templateEntityV1beta2Validator: KindValidator; + +// @public (undocumented) +interface UserEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: typeof API_VERSION_10[number]; + // (undocumented) + kind: typeof KIND_10; + // (undocumented) + spec: { + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + memberOf: string[]; + }; +} + +export { UserEntityV1alpha1 as UserEntity } + +export { UserEntityV1alpha1 } + +// @public (undocumented) +export const userEntityV1alpha1Validator: KindValidator; + +// @public (undocumented) +export type Validators = { + isValidApiVersion(value: unknown): boolean; + isValidKind(value: unknown): boolean; + isValidEntityName(value: unknown): boolean; + isValidNamespace(value: unknown): boolean; + isValidLabelKey(value: unknown): boolean; + isValidLabelValue(value: unknown): boolean; + isValidAnnotationKey(value: unknown): boolean; + isValidAnnotationValue(value: unknown): boolean; + isValidTag(value: unknown): boolean; +}; + +// @public +export const VIEW_URL_ANNOTATION = "backstage.io/view-url"; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 64550b35d1..941877aec5 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -33,7 +33,7 @@ "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", "ajv": "^7.0.3", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "lodash": "^4.17.15", "uuid": "^8.0.0", "yup": "^0.29.3" diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 5bb83d116b..53109ac7ee 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -66,7 +66,7 @@ "properties": { "type": { "type": "string", - "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", "examples": ["service", "website", "library"], "minLength": 1 }, diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index ba3ccb9d1d..1e0b7d0ded 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -85,7 +85,7 @@ "properties": { "type": { "type": "string", - "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", "examples": ["service", "website", "library"], "minLength": 1 }, diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md new file mode 100644 index 0000000000..bcdc023c22 --- /dev/null +++ b/packages/cli-common/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/cli-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public +export function findPaths(searchDir: string): Paths; + +// @public (undocumented) +export type Paths = { + ownDir: string; + ownRoot: string; + targetDir: string; + targetRoot: string; + resolveOwn: ResolveFunc; + resolveOwnRoot: ResolveFunc; + resolveTarget: ResolveFunc; + resolveTargetRoot: ResolveFunc; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a1381495ff..af452b3c9d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli +## 0.6.9 + +### Patch Changes + +- 4e5c94249: Add `config:docs` command that opens up reference documentation for the local configuration schema in a browser. +- 1373f4f12: No longer add newly created plugins to `plugins.ts` in the app, as it is no longer needed. +- 479b29124: Added support for Datadog rum events + ## 0.6.8 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 251b2ab076..1110efefe6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.6.8", + "version": "0.6.9", "private": false, "publishConfig": { "access": "public" @@ -82,7 +82,7 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "lodash": "^4.17.19", "mini-css-extract-plugin": "^0.9.0", "ora": "^5.3.0", @@ -116,12 +116,12 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.6.2", + "@backstage/backend-common": "^0.6.3", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md new file mode 100644 index 0000000000..a09311b786 --- /dev/null +++ b/packages/config-loader/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/config-loader" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AppConfig } from '@backstage/config'; +import { JsonObject } from '@backstage/config'; +import { JSONSchema7 } from 'json-schema'; + +// @public +export type ConfigSchema = { + process(appConfigs: AppConfig[], options?: ConfigProcessingOptions): AppConfig[]; + serialize(): JsonObject; +}; + +// @public +export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number]; + +// @public (undocumented) +export function loadConfig(options: LoadConfigOptions): Promise; + +// @public (undocumented) +export type LoadConfigOptions = { + configRoot: string; + configPaths: string[]; + env?: string; + experimentalEnvFunc?: EnvFunc; +}; + +// @public +export function loadConfigSchema(options: Options): Promise; + +// @public +export function mergeConfigSchemas(schemas: JSONSchema7[]): JSONSchema7; + +// @public +export function readEnvConfig(env: { + [name: string]: string | undefined; +}): AppConfig[]; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 0a0a76c2d6..55d86314af 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -35,7 +35,7 @@ "@types/json-schema": "^7.0.6", "ajv": "^7.0.3", "fs-extra": "^9.0.0", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "json-schema-merge-allof": "^0.7.0", "typescript-json-schema": "^0.49.0", "yaml": "^1.9.2", diff --git a/packages/config/api-report.md b/packages/config/api-report.md new file mode 100644 index 0000000000..be250778ec --- /dev/null +++ b/packages/config/api-report.md @@ -0,0 +1,90 @@ +## API Report File for "@backstage/config" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export type AppConfig = { + context: string; + data: JsonObject; +}; + +// @public (undocumented) +export type Config = { + has(key: string): boolean; + keys(): string[]; + get(key?: string): T; + getOptional(key?: string): T | undefined; + getConfig(key: string): Config; + getOptionalConfig(key: string): Config | undefined; + getConfigArray(key: string): Config[]; + getOptionalConfigArray(key: string): Config[] | undefined; + getNumber(key: string): number; + getOptionalNumber(key: string): number | undefined; + getBoolean(key: string): boolean; + getOptionalBoolean(key: string): boolean | undefined; + getString(key: string): string; + getOptionalString(key: string): string | undefined; + getStringArray(key: string): string[]; + getOptionalStringArray(key: string): string[] | undefined; +}; + +// @public (undocumented) +export class ConfigReader implements Config { + constructor(data: JsonObject | undefined, context?: string, fallback?: ConfigReader | undefined, prefix?: string); + // (undocumented) + static fromConfigs(configs: AppConfig[]): ConfigReader; + // (undocumented) + get(key?: string): T; + // (undocumented) + getBoolean(key: string): boolean; + // (undocumented) + getConfig(key: string): ConfigReader; + // (undocumented) + getConfigArray(key: string): ConfigReader[]; + // (undocumented) + getNumber(key: string): number; + // (undocumented) + getOptional(key?: string): T | undefined; + // (undocumented) + getOptionalBoolean(key: string): boolean | undefined; + // (undocumented) + getOptionalConfig(key: string): ConfigReader | undefined; + // (undocumented) + getOptionalConfigArray(key: string): ConfigReader[] | undefined; + // (undocumented) + getOptionalNumber(key: string): number | undefined; + // (undocumented) + getOptionalString(key: string): string | undefined; + // (undocumented) + getOptionalStringArray(key: string): string[] | undefined; + // (undocumented) + getString(key: string): string; + // (undocumented) + getStringArray(key: string): string[]; + // (undocumented) + has(key: string): boolean; + // (undocumented) + keys(): string[]; + } + +// @public (undocumented) +export interface JsonArray extends Array { +} + +// @public (undocumented) +export type JsonObject = { + [key in string]?: JsonValue; +}; + +// @public (undocumented) +export type JsonPrimitive = number | string | boolean | null; + +// @public (undocumented) +export type JsonValue = JsonObject | JsonArray | JsonPrimitive; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 609940b8db..fab82a77e5 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/core-api +## 0.2.17 + +### Patch Changes + +- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. +- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. +- Updated dependencies [931b21a12] + - @backstage/theme@0.2.6 + ## 0.2.16 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 33e5ac146d..a55df67343 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.16", + "version": "0.2.17", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -42,7 +42,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index 8ff4055e61..9f6412f960 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -47,12 +47,15 @@ export function createRoutableExtension< // Validate that the routing is wired up correctly in the App.tsx try { useRouteRef(mountPoint); - } catch { - throw new Error( - `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + - 'Routable extension components may not be rendered by other components and must be ' + - 'directly available as an element within the App provider component.', - ); + } catch (error) { + if (error?.message.startsWith('No path for ')) { + throw new Error( + `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + throw error; } return ; }; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7f691a2787..974dcd3b91 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/core +## 0.7.7 + +### Patch Changes + +- 9afcac5af: Allow passing NavLinkProps to SidebarItem component to use in NavLink +- e0c9ed759: Add `if` prop to `EntityLayout.Route` to conditionally render tabs +- 6eaecbd81: Improve owner example value in `MissingAnnotationEmptyState`. + +## 0.7.6 + +### Patch Changes + +- 94da20976: Sort the table filter options by name. +- d8cc7e67a: Exposing Material UI extension point for tabs to be able to add additional information to them +- 99fbef232: Adding Headings for Accessibility on the Scaffolder Plugin +- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. +- 937ed39ce: Exported SignInProviderConfig to strongly type SignInPage providers +- 9a9e7a42f: Adding close button on support menu +- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [50ce875a0] + - @backstage/core-api@0.2.17 + - @backstage/theme@0.2.6 + ## 0.7.5 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 7b1898a19d..59391e122a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.7.5", + "version": "0.7.7", "private": false, "publishConfig": { "access": "public", @@ -30,9 +30,9 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core-api": "^0.2.16", + "@backstage/core-api": "^0.2.17", "@backstage/errors": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -69,7 +69,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 377373a06d..6c04b34a90 100644 --- a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -30,7 +30,7 @@ metadata: spec: type: website lifecycle: production - owner: guest`; + owner: user:guest`; type Props = { annotation: string; diff --git a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx index b17c8afb3b..6b7b39329b 100644 --- a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -48,7 +48,12 @@ export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => { const navigate = useNavigate(); const { index, route, element } = useSelectedSubRoute(routes); const headerTabs = useMemo( - () => routes.map(t => ({ id: t.path, label: t.title })), + () => + routes.map(t => ({ + id: t.path, + label: t.title, + tabProps: t.tabProps, + })), [routes], ); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx index f181ee5980..5a1b190d00 100644 --- a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx @@ -23,11 +23,13 @@ import React, { ReactNode, } from 'react'; import { RoutedTabs } from './RoutedTabs'; +import { TabProps } from '@material-ui/core'; type SubRoute = { path: string; title: string; children: JSX.Element; + tabProps?: TabProps; }; const Route: (props: SubRoute) => null = () => null; @@ -60,8 +62,8 @@ export function createSubRoutesFromChildren( throw new Error('Child of TabbedLayout must be an TabbedLayout.Route'); } - const { path, title, children } = child.props; - return [{ path, title, children }]; + const { path, title, children, tabProps } = child.props; + return [{ path, title, children, tabProps }]; }); } diff --git a/packages/core/src/components/TabbedLayout/index.ts b/packages/core/src/components/TabbedLayout/index.ts index 744b56959e..fe72b199ec 100644 --- a/packages/core/src/components/TabbedLayout/index.ts +++ b/packages/core/src/components/TabbedLayout/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { TabbedLayout } from './TabbedLayout'; +export { RoutedTabs } from './RoutedTabs'; diff --git a/packages/core/src/components/TabbedLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts index 29ade88dc0..24ee011933 100644 --- a/packages/core/src/components/TabbedLayout/types.ts +++ b/packages/core/src/components/TabbedLayout/types.ts @@ -14,8 +14,12 @@ * limitations under the License. */ +import { TabProps } from '@material-ui/core'; +import * as React from 'react'; + export type SubRoute = { path: string; title: string; children: JSX.Element; + tabProps?: TabProps; }; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index d061be6d60..2251b8304e 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -363,7 +363,7 @@ export function Table({ placeholder: 'All results', label: filter.column, multiple: filter.type === 'multiple-select', - items: [...extractDistinctValues(filter.column)].map(value => ({ + items: [...extractDistinctValues(filter.column)].sort().map(value => ({ label: value, value, })), diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx index 81d2123049..9a5093f6e9 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { HeaderTabs } from './'; +import { Badge, makeStyles } from '@material-ui/core'; const mockTabs = [ { id: 'overview', label: 'Overview' }, @@ -46,4 +47,35 @@ describe('', () => { 'true', ); }); + it('should render extension component to tab if one present', async () => { + const useStyles = makeStyles(() => ({ + badge: { + margin: '20px 20px 0 0', + }, + })); + + const TextualBadge = React.forwardRef((props, ref) => ( + + + {props.children} + + + )); + const iconTab = [ + { + id: 'icon-tab', + label: 'Alarms', + tabProps: { component: TextualBadge }, + }, + ]; + + const rendered = await renderInTestApp(); + + expect(rendered.getByText('Alarms')).toBeInTheDocument(); + expect(rendered.getByText('three new alarms')).toBeInTheDocument(); + }); }); diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx index 718a103f00..1fd4a18ba3 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx @@ -18,7 +18,7 @@ // This is just a temporary solution to implementing tabs for now import React, { useState, useEffect } from 'react'; -import { makeStyles, Tabs, Tab as TabUI } from '@material-ui/core'; +import { makeStyles, Tabs, Tab as TabUI, TabProps } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ tabsWrapper: { @@ -41,6 +41,7 @@ const useStyles = makeStyles(theme => ({ export type Tab = { id: string; label: string; + tabProps?: TabProps; }; type HeaderTabsProps = { @@ -82,6 +83,7 @@ export const HeaderTabs = ({ > {tabs.map((tab, index) => ( (theme => { @@ -138,7 +138,7 @@ type SidebarItemButtonProps = SidebarItemBaseProps & { type SidebarItemLinkProps = SidebarItemBaseProps & { to: string; onClick?: (ev: React.MouseEvent) => void; -}; +} & NavLinkProps; type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps; @@ -156,6 +156,7 @@ export const SidebarItem = forwardRef((props, ref) => { onClick, children, className, + ...navLinkProps } = props; const classes = useStyles(); // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component @@ -216,6 +217,7 @@ export const SidebarItem = forwardRef((props, ref) => { activeClassName={classes.selected} to={props.to} ref={ref} + {...navLinkProps} > {content} diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 5975b78e06..430e7f003a 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,96 @@ # @backstage/create-app +## 0.3.20 + +### Patch Changes + +- 73f3f5d78: Updates the end to end test in the app to match the new catalog index page title. To apply this change to an existing app, update `packages/app/cypress/integration/app.js` to search for `"My Company Catalog"` instead of `"My Company Service Catalog"`. +- Updated dependencies [1ce80ff02] +- Updated dependencies [4c42ecca2] +- Updated dependencies [c614ede9a] +- Updated dependencies [9afcac5af] +- Updated dependencies [07a7806c3] +- Updated dependencies [f6efa71ee] +- Updated dependencies [19a4dd710] +- Updated dependencies [a99e0bc42] +- Updated dependencies [dcd54c7cd] +- Updated dependencies [da546ce00] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6fbd7beca] +- Updated dependencies [15cbe6815] +- Updated dependencies [39bdaa004] +- Updated dependencies [cb8c848a3] +- Updated dependencies [21fddf452] +- Updated dependencies [17915e29b] +- Updated dependencies [a1783f306] +- Updated dependencies [6eaecbd81] +- Updated dependencies [23769512a] +- Updated dependencies [1a142ae8a] + - @backstage/plugin-api-docs@0.4.12 + - @backstage/plugin-github-actions@0.4.4 + - @backstage/plugin-catalog-import@0.5.4 + - @backstage/plugin-explore@0.3.4 + - @backstage/plugin-lighthouse@0.2.15 + - @backstage/core@0.7.7 + - @backstage/plugin-scaffolder@0.9.2 + - @backstage/plugin-catalog@0.5.6 + - @backstage/plugin-catalog-backend@0.8.1 + - @backstage/plugin-search@0.3.5 + - @backstage/plugin-techdocs@0.9.0 + - @backstage/plugin-scaffolder-backend@0.10.1 + +## 0.3.19 + +### Patch Changes + +- ee22773e9: Removed `plugins.ts` from the app, as plugins are now discovered through the react tree. + + To apply this change to an existing app, simply delete `packages/app/src/plugins.ts` along with the import and usage in `packages/app/src/App.tsx`. + + Note that there are a few plugins that require explicit registration, in which case you would need to keep them in `plugins.ts`. The set of plugins that need explicit registration is any plugin that doesn't have a component extension that gets rendered as part of the app element tree. An example of such a plugin in the main Backstage repo is `@backstage/plugin-badges`. In the case of the badges plugin this is because there is not yet a component-based API for adding context menu items to the entity layout. + + If you have plugins that still rely on route registration through the `register` method of `createPlugin`, these need to be kept in `plugins.ts` as well. However, it is recommended to migrate these to export an extensions component instead. + +- 670acd88e: Fix system diagram card to be on the system page + + To apply the same fix to an existing application, in `EntityPage.tsx` simply move the `` for the `/diagram` path from the `groupPage` down into the `systemPage` element. + +- Updated dependencies [94da20976] +- Updated dependencies [84c54474d] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [4e5c94249] +- Updated dependencies [99fbef232] +- Updated dependencies [cb0206b2b] +- Updated dependencies [1373f4f12] +- Updated dependencies [29a7e4be8] +- Updated dependencies [ab07d77f6] +- Updated dependencies [49574a8a3] +- Updated dependencies [d367f63b5] +- Updated dependencies [96728a2af] +- Updated dependencies [5fe62f124] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [87c4f59de] +- Updated dependencies [09b5fcf2e] +- Updated dependencies [b42531cfe] +- Updated dependencies [c2306f898] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] +- Updated dependencies [ac6025f63] +- Updated dependencies [e292e393f] +- Updated dependencies [479b29124] + - @backstage/core@0.7.6 + - @backstage/plugin-scaffolder-backend@0.10.0 + - @backstage/cli@0.6.9 + - @backstage/plugin-scaffolder@0.9.1 + - @backstage/plugin-catalog-import@0.5.3 + - @backstage/plugin-rollbar-backend@0.1.9 + - @backstage/backend-common@0.6.3 + - @backstage/plugin-catalog@0.5.5 + - @backstage/plugin-catalog-backend@0.8.0 + - @backstage/theme@0.2.6 + - @backstage/plugin-techdocs@0.8.0 + ## 0.3.18 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index ea3d3548b8..071a0a5ebd 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.18", + "version": "0.3.20", "private": false, "publishConfig": { "access": "public" diff --git a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js index efcd5b8d93..43fb2e32de 100644 --- a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js +++ b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js @@ -1,6 +1,6 @@ describe('App', () => { it('should render the catalog', () => { cy.visit('/'); - cy.contains('My Company Service Catalog'); + cy.contains('My Company Catalog'); }); }); diff --git a/packages/dev-utils/api-report.md b/packages/dev-utils/api-report.md new file mode 100644 index 0000000000..d3e6447cd5 --- /dev/null +++ b/packages/dev-utils/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/dev-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiFactory } from '@backstage/core'; +import { ComponentType } from 'react'; +import { createPlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { GridProps } from '@material-ui/core'; +import { IconComponent } from '@backstage/core'; +import { ReactNode } from 'react'; + +// @public +export function createDevApp(): DevAppBuilder; + +// @public (undocumented) +export const EntityGridItem: ({ entity, classes, ...rest }: Omit, "container" | "item"> & { + entity: Entity; +}) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index c055bcd21e..ebf4e6c835 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -335,7 +335,7 @@ async function testAppServe(pluginName: string, appDir: string) { try { const browser = new Browser(); - await waitForPageWithText(browser, '/', 'My Company Service Catalog'); + await waitForPageWithText(browser, '/', 'My Company Catalog'); await waitForPageWithText( browser, `/${pluginName}`, diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md new file mode 100644 index 0000000000..cb24cba80f --- /dev/null +++ b/packages/errors/api-report.md @@ -0,0 +1,88 @@ +## API Report File for "@backstage/errors" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonObject } from '@backstage/config'; + +// @public +export class AuthenticationError extends CustomErrorBase { +} + +// @public +export class ConflictError extends CustomErrorBase { +} + +// @public (undocumented) +export class CustomErrorBase extends Error { + constructor(message?: string, cause?: Error); + // (undocumented) + readonly cause?: Error; +} + +// @public +export function deserializeError(data: SerializedError): T; + +// @public +export type ErrorResponse = { + error: SerializedError; + request?: { + method: string; + url: string; + }; + response: { + statusCode: number; + }; +}; + +// @public +export class InputError extends CustomErrorBase { +} + +// @public +export class NotAllowedError extends CustomErrorBase { +} + +// @public +export class NotFoundError extends CustomErrorBase { +} + +// @public +export class NotModifiedError extends CustomErrorBase { +} + +// @public +export function parseErrorResponse(response: Response): Promise; + +// @public +export class ResponseError extends Error { + constructor(props: { + message: string; + response: Response; + data: ErrorResponse; + cause: Error; + }); + readonly cause: Error; + readonly data: ErrorResponse; + static fromResponse(response: Response): Promise; + readonly response: Response; +} + +// @public +export type SerializedError = JsonObject & { + name: string; + message: string; + stack?: string; + code?: string; +}; + +// @public +export function serializeError(error: Error, options?: { + includeStack?: boolean; +}): SerializedError; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md new file mode 100644 index 0000000000..7c37ab0392 --- /dev/null +++ b/packages/integration-react/api-report.md @@ -0,0 +1,23 @@ +## API Report File for "@backstage/integration-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { Config } from '@backstage/config'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +// @public (undocumented) +export class ScmIntegrationsApi { + // (undocumented) + static fromConfig(config: Config): ScmIntegrationRegistry; +} + +// @public (undocumented) +export const scmIntegrationsApiRef: ApiRef; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md new file mode 100644 index 0000000000..5387eb12db --- /dev/null +++ b/packages/integration/api-report.md @@ -0,0 +1,279 @@ +## API Report File for "@backstage/integration" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; + +// @public (undocumented) +export class AzureIntegration implements ScmIntegration { + constructor(integrationConfig: AzureIntegrationConfig); + // (undocumented) + get config(): AzureIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type AzureIntegrationConfig = { + host: string; + token?: string; +}; + +// @public (undocumented) +export class BitbucketIntegration implements ScmIntegration { + constructor(integrationConfig: BitbucketIntegrationConfig); + // (undocumented) + get config(): BitbucketIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type BitbucketIntegrationConfig = { + host: string; + apiBaseUrl?: string; + token?: string; + username?: string; + appPassword?: string; +}; + +// @public +export function defaultScmResolveUrl(options: { + url: string; + base: string; + lineNumber?: number; +}): string; + +// @public +export function getAzureCommitsUrl(url: string): string; + +// @public +export function getAzureDownloadUrl(url: string): string; + +// @public +export function getAzureFileFetchUrl(url: string): string; + +// @public +export function getAzureRequestOptions(config: AzureIntegrationConfig, additionalHeaders?: Record): RequestInit; + +// @public +export function getBitbucketDefaultBranch(url: string, config: BitbucketIntegrationConfig): Promise; + +// @public +export function getBitbucketDownloadUrl(url: string, config: BitbucketIntegrationConfig): Promise; + +// @public +export function getBitbucketFileFetchUrl(url: string, config: BitbucketIntegrationConfig): string; + +// @public +export function getBitbucketRequestOptions(config: BitbucketIntegrationConfig): RequestInit; + +// @public +export function getGitHubFileFetchUrl(url: string, config: GitHubIntegrationConfig): string; + +// @public +export function getGitHubRequestOptions(config: GitHubIntegrationConfig): RequestInit; + +// @public +export function getGitLabFileFetchUrl(url: string, config: GitLabIntegrationConfig): Promise; + +// @public +export function getGitLabRequestOptions(config: GitLabIntegrationConfig): RequestInit; + +// @public (undocumented) +export class GithubCredentialsProvider { + // (undocumented) + static create(config: GitHubIntegrationConfig): GithubCredentialsProvider; + getCredentials(opts: { + url: string; + }): Promise; + } + +// @public (undocumented) +export class GitHubIntegration implements ScmIntegration { + constructor(integrationConfig: GitHubIntegrationConfig); + // (undocumented) + get config(): GitHubIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type GitHubIntegrationConfig = { + host: string; + apiBaseUrl?: string; + rawBaseUrl?: string; + token?: string; + apps?: GithubAppConfig[]; +}; + +// @public (undocumented) +export class GitLabIntegration implements ScmIntegration { + constructor(integrationConfig: GitLabIntegrationConfig); + // (undocumented) + get config(): GitLabIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type GitLabIntegrationConfig = { + host: string; + apiBaseUrl: string; + token?: string; + baseUrl: string; +}; + +// @public +export type GoogleGcsIntegrationConfig = { + clientEmail?: string; + privateKey?: string; +}; + +// @public +export function readAzureIntegrationConfig(config: Config): AzureIntegrationConfig; + +// @public +export function readAzureIntegrationConfigs(configs: Config[]): AzureIntegrationConfig[]; + +// @public +export function readBitbucketIntegrationConfig(config: Config): BitbucketIntegrationConfig; + +// @public +export function readBitbucketIntegrationConfigs(configs: Config[]): BitbucketIntegrationConfig[]; + +// @public +export function readGitHubIntegrationConfig(config: Config): GitHubIntegrationConfig; + +// @public +export function readGitHubIntegrationConfigs(configs: Config[]): GitHubIntegrationConfig[]; + +// @public +export function readGitLabIntegrationConfig(config: Config): GitLabIntegrationConfig; + +// @public +export function readGitLabIntegrationConfigs(configs: Config[]): GitLabIntegrationConfig[]; + +// @public +export function readGoogleGcsIntegrationConfig(config: Config): GoogleGcsIntegrationConfig; + +// @public +export interface ScmIntegration { + resolveEditUrl(url: string): string; + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + title: string; + type: string; +} + +// @public +export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { + // (undocumented) + azure: ScmIntegrationsGroup; + // (undocumented) + bitbucket: ScmIntegrationsGroup; + // (undocumented) + github: ScmIntegrationsGroup; + // (undocumented) + gitlab: ScmIntegrationsGroup; + resolveEditUrl(url: string): string; + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; +} + +// @public (undocumented) +export class ScmIntegrations implements ScmIntegrationRegistry { + constructor(integrationsByType: IntegrationsByType); + // (undocumented) + get azure(): ScmIntegrationsGroup; + // (undocumented) + get bitbucket(): ScmIntegrationsGroup; + // (undocumented) + byHost(host: string): ScmIntegration | undefined; + // (undocumented) + byUrl(url: string | URL): ScmIntegration | undefined; + // (undocumented) + static fromConfig(config: Config): ScmIntegrations; + // (undocumented) + get github(): ScmIntegrationsGroup; + // (undocumented) + get gitlab(): ScmIntegrationsGroup; + // (undocumented) + list(): ScmIntegration[]; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; +} + +// @public +export interface ScmIntegrationsGroup { + byHost(host: string): T | undefined; + byUrl(url: string | URL): T | undefined; + list(): T[]; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/search-common/api-report.md b/packages/search-common/api-report.md new file mode 100644 index 0000000000..76bd937a1c --- /dev/null +++ b/packages/search-common/api-report.md @@ -0,0 +1,55 @@ +## API Report File for "@backstage/search-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonObject } from '@backstage/config'; + +// @public +export interface DocumentCollator { + // (undocumented) + execute(): Promise; +} + +// @public +export interface DocumentDecorator { + // (undocumented) + execute(documents: IndexableDocument[]): Promise; +} + +// @public +export interface IndexableDocument { + location: string; + text: string; + title: string; +} + +// @public (undocumented) +export interface SearchQuery { + // (undocumented) + filters?: JsonObject; + // (undocumented) + pageCursor: string; + // (undocumented) + term: string; + // (undocumented) + types?: string[]; +} + +// @public (undocumented) +export interface SearchResult { + // (undocumented) + document: IndexableDocument; +} + +// @public (undocumented) +export interface SearchResultSet { + // (undocumented) + results: SearchResult[]; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/search-common/src/types.ts b/packages/search-common/src/types.ts index ad784c3b36..daa5823424 100644 --- a/packages/search-common/src/types.ts +++ b/packages/search-common/src/types.ts @@ -18,6 +18,7 @@ import { JsonObject } from '@backstage/config'; export interface SearchQuery { term: string; filters?: JsonObject; + types?: string[]; pageCursor: string; } diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 9dd6447a14..1788502e45 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/techdocs-common +## 0.5.1 + +### Patch Changes + +- f4af06ebe: Gracefully handle HTTP request failures in download method of AzureBlobStorage publisher. + ## 0.5.0 ### Minor Changes diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md new file mode 100644 index 0000000000..a53fb9e681 --- /dev/null +++ b/packages/techdocs-common/api-report.md @@ -0,0 +1,178 @@ +## API Report File for "@backstage/techdocs-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegrationConfig } from '@backstage/integration'; +import { Config } from '@backstage/config'; +import Docker from 'dockerode'; +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import express from 'express'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { GitLabIntegrationConfig } from '@backstage/integration'; +import { Logger } from 'winston'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; +import { Writable } from 'stream'; + +// @public (undocumented) +export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger) => Promise; + +// @public (undocumented) +export class CommonGitPreparer implements PreparerBase { + constructor(config: Config, logger: Logger); + // (undocumented) + prepare(entity: Entity, options?: { + etag?: string; + }): Promise; +} + +// @public (undocumented) +export class DirectoryPreparer implements PreparerBase { + constructor(config: Config, logger: Logger, reader: UrlReader); + // (undocumented) + prepare(entity: Entity): Promise; + } + +// @public (undocumented) +export type GeneratorBase = { + run(opts: GeneratorRunOptions): Promise; +}; + +// @public +export type GeneratorBuilder = { + register(protocol: SupportedGeneratorKey, generator: GeneratorBase): void; + get(entity: Entity): GeneratorBase; +}; + +// @public (undocumented) +export class Generators implements GeneratorBuilder { + // (undocumented) + static fromConfig(config: Config, { logger }: { + logger: Logger; + }): Promise; + // (undocumented) + get(entity: Entity): GeneratorBase; + // (undocumented) + register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase): void; +} + +// @public (undocumented) +export const getAzureIntegrationConfig: (config: Config, host: string) => AzureIntegrationConfig; + +// @public (undocumented) +export const getDefaultBranch: (repositoryUrl: string, config: Config) => Promise; + +// @public (undocumented) +export const getDocFilesFromRepository: (reader: UrlReader, entity: Entity, opts?: { + etag?: string | undefined; + logger?: Logger | undefined; +} | undefined) => Promise; + +// @public (undocumented) +export function getGitHost(url: string): string; + +// @public (undocumented) +export const getGitHubIntegrationConfig: (config: Config, host: string) => GitHubIntegrationConfig; + +// @public (undocumented) +export const getGitLabIntegrationConfig: (config: Config, host: string) => GitLabIntegrationConfig; + +// @public (undocumented) +export const getGitRepositoryTempFolder: (repositoryUrl: string, config: Config) => Promise; + +// @public (undocumented) +export function getGitRepoType(url: string): string; + +// @public (undocumented) +export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger) => Promise; + +// @public (undocumented) +export const getLocationForEntity: (entity: Entity) => ParsedLocationAnnotation; + +// @public (undocumented) +export const getTokenForGitRepo: (repositoryUrl: string, config: Config) => Promise; + +// @public (undocumented) +export type ParsedLocationAnnotation = { + type: RemoteProtocol; + target: string; +}; + +// @public (undocumented) +export const parseReferenceAnnotation: (annotationName: string, entity: Entity) => ParsedLocationAnnotation; + +// @public (undocumented) +export type PreparerBase = { + prepare(entity: Entity, options?: { + logger?: Logger; + etag?: string; + }): Promise; +}; + +// @public (undocumented) +export type PreparerBuilder = { + register(protocol: RemoteProtocol, preparer: PreparerBase): void; + get(entity: Entity): PreparerBase; +}; + +// @public (undocumented) +export class Preparers implements PreparerBuilder { + // (undocumented) + static fromConfig(config: Config, { logger, reader }: factoryOptions): Promise; + // (undocumented) + get(entity: Entity): PreparerBase; + // (undocumented) + register(protocol: RemoteProtocol, preparer: PreparerBase): void; +} + +// @public +export class Publisher { + // (undocumented) + static fromConfig(config: Config, { logger, discovery }: factoryOptions_2): Promise; +} + +// @public +export interface PublisherBase { + docsRouter(): express.Handler; + fetchTechDocsMetadata(entityName: EntityName): Promise; + getReadiness(): Promise; + hasDocsBeenGenerated(entityName: Entity): Promise; + publish(request: PublishRequest): Promise; +} + +// @public +export type PublisherType = 'local' | 'googleGcs' | 'awsS3' | 'azureBlobStorage' | 'openStackSwift'; + +// @public +export type RemoteProtocol = 'url' | 'dir' | 'github' | 'gitlab' | 'file' | 'azure/api'; + +// @public (undocumented) +export class TechdocsGenerator implements GeneratorBase { + constructor(logger: Logger, config: Config); + // (undocumented) + run({ inputDir, outputDir, dockerClient, parsedLocationAnnotation, etag, }: GeneratorRunOptions): Promise; +} + +// @public +export type TechDocsMetadata = { + site_name: string; + site_description: string; + etag: string; +}; + +// @public (undocumented) +export class UrlPreparer implements PreparerBase { + constructor(reader: UrlReader, logger: Logger); + // (undocumented) + prepare(entity: Entity, options?: { + etag?: string; + }): Promise; + } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index c41dce72b1..e749b33919 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 15da3b7cfc..f094457346 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, EntityName, + ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -154,7 +155,6 @@ describe('AwsS3Publish', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(3); const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -171,23 +171,22 @@ describe('AwsS3Publish', () => { }), ).rejects.toThrowError(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - expect(error.message).toEqual( - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory`, - ), - ); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + 'Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory', + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); + mockFs.restore(); }); }); @@ -265,18 +264,12 @@ describe('AwsS3Publish', () => { const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); - await publisher - .fetchTechDocsMetadata(entityNameMock) - .catch(error => - expect(error).toEqual( - new Error( - `TechDocs metadata fetch failed, The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), - ), - ); + const fails = publisher.fetchTechDocsMetadata(entityNameMock); + + const errorPath = path.join(entityRootDir, 'techdocs_metadata.json'); + await expect(fails).rejects.toMatchObject({ + message: `TechDocs metadata fetch failed, The file ${errorPath} does not exist !`, + }); }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 9c6684fdca..d628c860fd 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -80,10 +80,17 @@ export class AwsS3Publish implements PublisherBase { 'techdocs.publisher.awsS3.endpoint', ); + // AWS forcePathStyle is an optional config. If missing, it defaults to false. Needs to be enabled for cases + // where endpoint url points to locally hosted S3 compatible storage like Localstack + const s3ForcePathStyle = config.getOptionalBoolean( + 'techdocs.publisher.awsS3.s3ForcePathStyle', + ); + const storageClient = new aws.S3({ credentials, ...(region && { region }), ...(endpoint && { endpoint }), + ...(s3ForcePathStyle && { s3ForcePathStyle }), }); return new AwsS3Publish(storageClient, bucketName, logger); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index c431e924a8..1160ffe49d 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, EntityName, + ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -157,7 +158,6 @@ describe('publishing with valid credentials', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(1); const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -168,22 +168,20 @@ describe('publishing with valid credentials', () => { const entity = createMockEntity(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to Azure Blob Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, - ); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); mockFs.restore(); }); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 1a823c3d19..f6567abe49 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -221,7 +221,8 @@ export class AzureBlobStoragePublish implements PublisherBase { .on('end', () => { resolve(Buffer.concat(fileStreamChunks)); }); - }); + }) + .catch(reject); }); } diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 7e580b16bc..19e4938763 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, @@ -146,8 +147,6 @@ describe('GoogleGCSPublish', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(3); - const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -165,23 +164,21 @@ describe('GoogleGCSPublish', () => { }), ).rejects.toThrowError(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - expect(error.message).toEqual( - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, - ), - ); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + `Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); mockFs.restore(); }); @@ -264,16 +261,14 @@ describe('GoogleGCSPublish', () => { const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); - await publisher - .fetchTechDocsMetadata(entityNameMock) - .catch(errorMessage => - expect(errorMessage).toEqual( - `The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), - ); + const fails = publisher.fetchTechDocsMetadata(entityNameMock); + + await expect(fails).rejects.toMatchObject({ + message: `The file ${path.join( + entityRootDir, + 'techdocs_metadata.json', + )} does not exist !`, + }); }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 28fbb42ece..8adafc361a 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -168,7 +168,7 @@ export class GoogleGCSPublish implements PublisherBase { .createReadStream() .on('error', err => { this.logger.error(err.message); - reject(err.message); + reject(err); }) .on('data', chunk => { fileStreamChunks.push(chunk); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index 6f0aacede0..e179aeb9a7 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, EntityName, + ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -157,7 +158,6 @@ describe('OpenStackSwiftPublish', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(3); const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -174,23 +174,22 @@ describe('OpenStackSwiftPublish', () => { }), ).rejects.toThrowError(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - expect(error.message).toEqual( - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`, - ), - ); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + `Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); + mockFs.restore(); }); }); @@ -268,18 +267,14 @@ describe('OpenStackSwiftPublish', () => { const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); - await publisher - .fetchTechDocsMetadata(entityNameMock) - .catch(error => - expect(error).toEqual( - new Error( - `TechDocs metadata fetch failed, The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), - ), - ); + const fails = publisher.fetchTechDocsMetadata(entityNameMock); + + await expect(fails).rejects.toMatchObject({ + message: `TechDocs metadata fetch failed, The file ${path.join( + entityRootDir, + 'techdocs_metadata.json', + )} does not exist !`, + }); }); }); }); diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md new file mode 100644 index 0000000000..4b603b8e7b --- /dev/null +++ b/packages/test-utils-core/api-report.md @@ -0,0 +1,86 @@ +## API Report File for "@backstage/test-utils-core" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ReactElement } from 'react'; +import { RenderResult } from '@testing-library/react'; + +// @public (undocumented) +export type AsyncLogCollector = () => Promise; + +// @public (undocumented) +export type CollectedLogs = { + [key in T]: string[]; +}; + +// @public (undocumented) +export class Keyboard { + constructor(target: any, { debug }?: { + debug?: boolean | undefined; + }); + // (undocumented) + click(): Promise; + // (undocumented) + debug: boolean; + // (undocumented) + document: any; + // (undocumented) + enter(value: any): Promise; + // (undocumented) + escape(): Promise; + // (undocumented) + get focused(): any; + // (undocumented) + static fromReadableInput(input: any): any; + // (undocumented) + _log(message: any, ...args: any[]): void; + // (undocumented) + _pretty(element: any): string; + // (undocumented) + send(chars: any): Promise; + // (undocumented) + _sendKey(key: any, charCode: any, action: any): Promise; + // (undocumented) + tab(): Promise; + // (undocumented) + static toReadableInput(chars: any): any; + // (undocumented) + toString(): string; + // (undocumented) + static type(target: any, input: any): Promise; + // (undocumented) + type(input: any): Promise; + // (undocumented) + static typeDebug(target: any, input: any): Promise; +} + +// @public (undocumented) +export type LogCollector = AsyncLogCollector | SyncLogCollector; + +// @public (undocumented) +export type LogFuncs = 'log' | 'warn' | 'error'; + +// @public (undocumented) +export function renderWithEffects(nodes: ReactElement): Promise; + +// @public (undocumented) +export type SyncLogCollector = () => void; + +// @public (undocumented) +export function withLogCollector(callback: AsyncLogCollector): Promise>; + +// @public (undocumented) +export function withLogCollector(callback: SyncLogCollector): CollectedLogs; + +// @public (undocumented) +export function withLogCollector(logsToCollect: T[], callback: AsyncLogCollector): Promise>; + +// @public (undocumented) +export function withLogCollector(logsToCollect: T[], callback: SyncLogCollector): CollectedLogs; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md new file mode 100644 index 0000000000..dcac9b64a7 --- /dev/null +++ b/packages/test-utils/api-report.md @@ -0,0 +1,82 @@ +## API Report File for "@backstage/test-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentType } from 'react'; +import { ErrorApi } from '@backstage/core-api'; +import { ErrorContext } from '@backstage/core-api'; +import { ExternalRouteRef } from '@backstage/core-api'; +import { Observable } from '@backstage/core-api'; +import { ReactElement } from 'react'; +import { ReactNode } from 'react'; +import { RenderResult } from '@testing-library/react'; +import { RouteRef } from '@backstage/core-api'; +import { StorageApi } from '@backstage/core-api'; +import { StorageValueChange } from '@backstage/core-api'; + +// @public (undocumented) +export function mockBreakpoint(initialBreakpoint?: Breakpoint): { + set(breakpoint: Breakpoint): void; + remove(): void; +}; + +// @public (undocumented) +export class MockErrorApi implements ErrorApi { + constructor(options?: Options); + // (undocumented) + error$(): Observable<{ + error: Error; + context?: ErrorContext; + }>; + // (undocumented) + getErrors(): ErrorWithContext[]; + // (undocumented) + post(error: Error, context?: ErrorContext): void; + // (undocumented) + waitForError(pattern: RegExp, timeoutMs?: number): Promise; +} + +// @public (undocumented) +export class MockStorageApi implements StorageApi { + // (undocumented) + static create(data?: MockStorageBucket): MockStorageApi; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + get(key: string): T | undefined; + // (undocumented) + observe$(key: string): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + } + +// @public (undocumented) +export type MockStorageBucket = { + [key: string]: any; +}; + +// @public (undocumented) +export const msw: { + setupDefaultHandlers: (worker: { + listen: (t: any) => void; + close: () => void; + resetHandlers: () => void; + }) => void; +}; + +// @public +export function renderInTestApp(Component: ComponentType | ReactNode, options?: TestAppOptions): Promise; + +// @public +export function wrapInTestApp(Component: ComponentType | ReactNode, options?: TestAppOptions): ReactElement; + + +export * from "@backstage/test-utils-core"; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 9ce3c75802..22964d6b25 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/theme +## 0.2.6 + +### Patch Changes + +- 931b21a12: Replace the link color in dark theme + ## 0.2.5 ### Patch Changes diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md new file mode 100644 index 0000000000..93d0957f5a --- /dev/null +++ b/packages/theme/api-report.md @@ -0,0 +1,89 @@ +## API Report File for "@backstage/theme" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Overrides } from '@material-ui/core/styles/overrides'; +import { Palette } from '@material-ui/core/styles/createPalette'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; +import { Theme } from '@material-ui/core'; +import { ThemeOptions } from '@material-ui/core'; + +// @public (undocumented) +export type BackstagePalette = Palette & PaletteAdditions; + +// @public (undocumented) +export type BackstagePaletteOptions = PaletteOptions & PaletteAdditions; + +// @public (undocumented) +export interface BackstageTheme extends Theme { + // (undocumented) + getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme; + // (undocumented) + page: PageTheme; + // (undocumented) + palette: BackstagePalette; +} + +// @public (undocumented) +export interface BackstageThemeOptions extends ThemeOptions { + // (undocumented) + getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme; + // (undocumented) + page: PageTheme; + // (undocumented) + palette: BackstagePaletteOptions; +} + +// @public (undocumented) +export const colorVariants: Record; + +// @public (undocumented) +export function createTheme(options: SimpleThemeOptions): BackstageTheme; + +// @public (undocumented) +export function createThemeOptions(options: SimpleThemeOptions): BackstageThemeOptions; + +// @public (undocumented) +export function createThemeOverrides(theme: BackstageTheme): Overrides; + +// @public (undocumented) +export const darkTheme: BackstageTheme; + +// @public (undocumented) +export function genPageTheme(colors: string[], shape: string): PageTheme; + +// @public (undocumented) +export const lightTheme: BackstageTheme; + +// @public (undocumented) +export type PageTheme = { + colors: string[]; + shape: string; + backgroundImage: string; +}; + +// @public (undocumented) +export const pageTheme: Record; + +// @public (undocumented) +export type PageThemeSelector = { + themeId: string; +}; + +// @public (undocumented) +export const shapes: Record; + +// @public +export type SimpleThemeOptions = { + palette: BackstagePaletteOptions; + defaultPageTheme: string; + pageTheme?: Record; + fontFamily?: string; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/theme/package.json b/packages/theme/package.json index b10e01ca1b..87821b6751 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.5", + "version": "0.2.6", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6" + "@backstage/cli": "^0.6.9" }, "files": [ "dist" diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 0cdd41508c..94cf38656e 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -102,7 +102,8 @@ export const darkTheme = createTheme({ }, }, primary: { - main: '#8CB4E5', + main: '#9CC9FF', + dark: '#82BAFD', }, secondary: { main: '#FF88B2', @@ -124,8 +125,8 @@ export const darkTheme = createTheme({ errorText: '#CA001B', infoText: '#004e8a', warningText: '#000000', - linkHover: '#2196F3', - link: '#0A6EBE', + linkHover: '#82BAFD', + link: '#9CC9FF', gold: yellow.A700, navigation: { background: '#424242', diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index a2d5a28e41..f268ba1c4d 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-api-docs +## 0.4.12 + +### Patch Changes + +- 1ce80ff02: Resolve issues with AsyncAPI rendering by updating `@asyncapi/react-component` + to `0.23.0`. The theming of the component is adjusted to the latest styling + changes. +- c614ede9a: Updated README to have up-to-date install instructions. +- 07a7806c3: Added fields filtering in get API entities to avoid the requesting of unused data +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.4.11 ### Patch Changes diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 4e71e87275..dac4b6ff4d 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -33,17 +33,7 @@ To link that a component provides or consumes an API, see the [`providesApis`](h yarn add @backstage/plugin-api-docs ``` -2. Add the plugin to the app: - -```ts -// packages/app/src/plugins.ts - -export { apiDocsPlugin } from '@backstage/plugin-api-docs'; -``` - -} /> - -3. Register the `ApiExplorerPage` at the `/api-docs` path: +2. Add the `ApiExplorerPage` extension to the app: ```tsx // packages/app/src/App.tsx @@ -53,7 +43,7 @@ import { ApiExplorerPage } from '@backstage/plugin-api-docs'; } />; ``` -4. Add one of the provided widgets to the EntityPage: +3. Add one of the provided widgets to the EntityPage: ```tsx // packages/app/src/components/catalog/EntityPage.tsx diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 13f27c321b..5c9f9ca7ee 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.11", + "version": "0.4.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@asyncapi/react-component": "^0.22.3", + "@asyncapi/react-component": "^0.23.0", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,7 +49,7 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 83d0bfc2ba..1ed47811ef 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -34,7 +34,19 @@ export const ApiExplorerPage = () => { const createComponentLink = useRouteRef(createComponentRouteRef); const catalogApi = useApi(catalogApiRef); const { loading, error, value: catalogResponse } = useAsync(() => { - return catalogApi.getEntities({ filter: { kind: 'API' } }); + return catalogApi.getEntities({ + filter: { kind: 'API' }, + fields: [ + 'apiVersion', + 'kind', + 'metadata', + 'relations', + 'spec.lifecycle', + 'spec.owner', + 'spec.type', + 'spec.system', + ], + }); }, [catalogApi]); return ( diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index a908a91b14..300c9d0c8a 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -88,18 +88,10 @@ const useStyles = makeStyles(theme => ({ '& .asyncapi__enum': { color: theme.palette.secondary.main, }, - '& .asyncapi__toggle-arrow:before': { - content: '">"', - 'font-family': 'inherit', - }, - '& .asyncapi__anchor-icon:before': { - content: '"🔗"', - 'font-family': 'inherit', - }, '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': { 'background-color': 'inherit', }, - '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header': { + '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': { 'background-color': 'inherit', color: theme.palette.text.primary, }, diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 0d59e271b0..8f7b5865a1 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,10 +21,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md index 66314933bb..ecb26efd50 100644 --- a/plugins/bitrise/CHANGELOG.md +++ b/plugins/bitrise/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-bitrise +## 0.1.2 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.1.1 ### Patch Changes diff --git a/plugins/bitrise/README.md b/plugins/bitrise/README.md index 59d85b0a98..411db3198e 100644 --- a/plugins/bitrise/README.md +++ b/plugins/bitrise/README.md @@ -8,36 +8,30 @@ Welcome to the Bitrise plugin! ## Installation ```sh +# The plugin must be added in the app package +$ cd packages/app $ yarn add @backstage/plugin-bitrise ``` -Then make sure to export the plugin in your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: - -```js -export { bitrisePlugin } from '@backstage/plugin-bitrise'; -``` - -Bitrise Plugin exposes an "Entity Tab Content" component `EntityBitriseContent`. You can include it in the [`EntityPage.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/catalog/EntityPage.tsx)`: +Bitrise Plugin exposes an entity tab component named `EntityBitriseContent`. You can include it in the +[`EntityPage.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/catalog/EntityPage.tsx)`: ```tsx // At the top imports import { EntityBitriseContent } from '@backstage/plugin-bitrise'; -// Inside `WebsiteEntityPage` component - - - - - - } -/>; +// Farther down at the website declaration +const websiteEntityPage = ( + + {/* Place the following section where you want the tab to appear */} + + + ``` -Now your plugin should be visible in the entity page, however in a state with a missing `bitrise.io/app` annotation. +Now your plugin should be visible as a tab at the top of the entity pages, +specifically for components that are of the type `website`. +However, it warns of a missing `bitrise.io/app` annotation. Add the annotation to your component [catalog-info.yaml](https://github.com/backstage/backstage/blob/master/catalog-info.yaml) as shown in the highlighted example below: diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 16a02ae42a..950392f732 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bitrise", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,7 +37,7 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 94e9960e35..ed87e4dbc5 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,63 @@ # @backstage/plugin-catalog-backend +## 0.8.1 + +### Patch Changes + +- a99e0bc42: Entity lifecycle and owner are now indexed by the `DefaultCatalogCollator`. A `locationTemplate` may now optionally be provided to its constructor to reflect a custom catalog entity path in the Backstage frontend. +- Updated dependencies [e1e757569] + - @backstage/plugin-search-backend-node@0.1.4 + +## 0.8.0 + +### Minor Changes + +- 5fe62f124: Fix the schema / code mismatch in LDAP `set` config + +### Patch Changes + +- 09b5fcf2e: GithubDiscoveryProcessor now excludes archived repositories so they won't be added to Backstage. +- c2306f898: Externalize repository processing for BitbucketDiscoveryProcessor. + + Add an extension point where you can customize how a matched Bitbucket repository should + be processed. This can for example be used if you want to generate the catalog-info.yaml + automatically based on other files in a repository, while taking advantage of the + build-in repository crawling functionality. + + `BitbucketDiscoveryProcessor.fromConfig` now takes an optional parameter `options.parser` where + you can customize the logic for each repository found. The default parser has the same + behaviour as before, where it emits an optional location for the matched repository + and lets the other processors take care of further processing. + + ```typescript + const customRepositoryParser: BitbucketRepositoryParser = async function* customRepositoryParser({ + client, + repository, + }) { + // Custom logic for interpret the matching repository. + // See defaultRepositoryParser for an example + }; + + const processor = BitbucketDiscoveryProcessor.fromConfig(env.config, { + parser: customRepositoryParser, + logger: env.logger, + }); + ``` + +- Updated dependencies [94da20976] +- Updated dependencies [b9b2b4b76] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [d367f63b5] +- Updated dependencies [937ed39ce] +- Updated dependencies [b42531cfe] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/backend-common@0.6.3 + ## 0.7.1 ### Patch Changes diff --git a/plugins/catalog-backend/migrationsv2/20200511113813_init.js b/plugins/catalog-backend/migrationsv2/20200511113813_init.js new file mode 100644 index 0000000000..7f3d75e35c --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200511113813_init.js @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + return ( + knex.schema + // + // locations + // + .createTable('locations', table => { + table.comment( + 'Registered locations that shall be contiuously scanned for catalog item updates', + ); + table + .uuid('id') + .primary() + .notNullable() + .comment('Auto-generated ID of the location'); + table.string('type').notNullable().comment('The type of location'); + table + .string('target') + .notNullable() + .comment('The actual target of the location'); + }) + // + // entities + // + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .string('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .string('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }) + // + // entities_search + // + .createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }) + ); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema + .dropTable('entities_search') + .alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }) + .dropTable('entities') + .dropTable('locations'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js b/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js new file mode 100644 index 0000000000..d8093fc9b4 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + return knex.schema.createTable('location_update_log', table => { + table.uuid('id').primary(); + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema.dropTableIfExists('location_update_log'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js b/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js new file mode 100644 index 0000000000..a0f0f33a65 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Get list sorted by created_at timestamp in descending order + // Grouped by location_id + return knex.schema.raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + ORDER BY created_at DESC; + `); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200702153613_entities.js b/plugins/catalog-backend/migrationsv2/20200702153613_entities.js new file mode 100644 index 0000000000..0f1c204f9b --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200702153613_entities.js @@ -0,0 +1,236 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.dropForeign(['entity_id']); + }); + await knex.schema.alterTable('entities', table => { + table.dropPrimary('entities_pkey'); + }); + } + await knex.schema.alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }); + // Setup temporary tables + await knex.schema.renameTable('entities_search', 'tmp_entities_search'); + await knex.schema.renameTable('entities', 'tmp_entities'); + + // + // entities + // + await knex.schema + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .text('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .text('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }); + + await knex.schema.raw(`INSERT INTO entities SELECT * FROM tmp_entities`); + + // + // entities_search + // + await knex.schema.createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }); + await knex.schema.raw( + `INSERT INTO entities_search SELECT * FROM tmp_entities_search`, + ); + + // Clean up + await knex.schema.dropTable('tmp_entities'); + return knex.schema.dropTable('tmp_entities_search'); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.dropForeign(['entity_id']); + }); + await knex.schema.alterTable('entities', table => { + table.dropPrimary('entities_pkey'); + }); + } + await knex.schema.alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }); + + // Setup temporary tables + await knex.schema.renameTable('entities_search', 'tmp_entities_search'); + await knex.schema.renameTable('entities', 'tmp_entities'); + + // + // entities + // + await knex.schema + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .string('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .string('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }); + + await knex.schema.raw(`INSERT INTO entities SELECT * FROM tmp_entities`); + + // + // entities_search + // + await knex.schema.createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }); + await knex.schema.raw( + `INSERT INTO entities_search SELECT * FROM tmp_entities_search`, + ); + + // Clean up + await knex.schema.dropTable('tmp_entities'); + return knex.schema.dropTable('tmp_entities_search'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js b/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js new file mode 100644 index 0000000000..87b41a80fc --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = function up(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; +`); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function down(knex) { + knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js b/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js new file mode 100644 index 0000000000..de2b194cff --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = function up(knex) { + return knex.schema + .raw('DROP VIEW location_update_log_latest;') + .dropTable('location_update_log') + .createTable('location_update_log', table => { + table.bigIncrements('id').primary(); // instead of uuid, so we can MAX it + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function down(knex) { + return knex.schema + .raw('DROP VIEW location_update_log_latest;') + .dropTable('location_update_log') + .createTable('location_update_log', table => { + table.uuid('id').primary(); + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js b/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js new file mode 100644 index 0000000000..45226e53b4 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.text('value').nullable().alter(); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.string('value').nullable().alter(); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js b/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js new file mode 100644 index 0000000000..a90813fe85 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Adds a single 'bootstrap' location that can be used to trigger work in processors. + // This is primarily here to fulfill foreign key constraints. + await knex('locations').insert({ + id: require('uuid').v4(), + type: 'bootstrap', + target: 'bootstrap', + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex('locations') + .where({ + type: 'bootstrap', + target: 'bootstrap', + }) + .del(); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js b/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js new file mode 100644 index 0000000000..ea5ba9e58d --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex('entities') + .where({ namespace: null }) + .update({ namespace: 'default' }); + await knex('entities_search').update({ + key: knex.raw('LOWER(key)'), + value: knex.raw('LOWER(value)'), + }); +}; + +exports.down = async function down() {}; diff --git a/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js new file mode 100644 index 0000000000..aae1861658 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.text('full_name').nullable(); + }); + + await knex('entities').update({ + full_name: knex.raw( + "LOWER(kind) || ':' || LOWER(COALESCE(namespace, 'default')) || '/' || LOWER(name)", + ), + }); + + // SQLite does not support alter column + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.text('full_name').notNullable().alter(); + }); + } + + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['full_name'], 'entities_unique_full_name'); + table.dropUnique([], 'entities_unique_name'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.dropUnique([], 'entities_unique_full_name'); + table.unique(['kind', 'namespace', 'name'], 'entities_unique_name'); + }); + + await knex.schema.alterTable('entities_search', table => { + table.dropColumn('full_name'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js b/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js new file mode 100644 index 0000000000..a8964efbf6 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table + .text('data') + .nullable() + .comment('The entire JSON data blob of the entity'); + }); + + await knex('entities').update({ + // apiVersion and kind should not contain any JSON unsafe chars, and both + // metadata and spec are already valid serialized JSON + data: knex.raw( + `'{"apiVersion":"' || api_version || '","kind":"' || kind || '","metadata":' || metadata || COALESCE(',"spec":' || spec, '') || '}'`, + ), + }); + + await knex.schema.alterTable('entities', table => { + table.dropColumn('metadata'); + table.dropColumn('spec'); + }); + + // SQLite does not support ALTER COLUMN. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.text('data').notNullable().alter(); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + table + .text('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .text('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + table.dropColumn('data'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js b/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js new file mode 100644 index 0000000000..f40df5f73e --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.dropColumn('api_version'); + table.dropColumn('kind'); + table.dropColumn('name'); + table.dropColumn('namespace'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table.string('kind').notNullable().comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js b/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js new file mode 100644 index 0000000000..77bf0529eb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities_search', table => { + table.index(['key'], 'entities_search_key'); + table.index(['value'], 'entities_search_value'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities_search', table => { + table.dropIndex('', 'entities_search_key'); + table.dropIndex('', 'entities_search_value'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js b/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js new file mode 100644 index 0000000000..85e729f814 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('entities_relations'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js b/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js new file mode 100644 index 0000000000..9e8198b5eb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + // sqlite doesn't support dropPrimary so we recreate it properly instead + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + table.index('source_full_name', 'source_full_name_idx'); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropPrimary(); + table.index('source_full_name', 'source_full_name_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'source_full_name_idx'); + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js b/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js new file mode 100644 index 0000000000..abb26cd5fc --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.index('originating_entity_id', 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_search', table => { + table.index('entity_id', 'entity_id_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'entity_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js b/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js new file mode 100644 index 0000000000..d924b0414a --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + // We actually just want to widen columns, but can't do that while a + // view is dependent on them - so we just reconstruct it exactly as it was + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.text('message').alter(); + table.text('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.string('message').alter(); + table.string('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js b/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js new file mode 100644 index 0000000000..ccfb1faffb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.index('location_id', 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.index('location_id', 'update_log_location_id_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.dropIndex([], 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.dropIndex([], 'update_log_location_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 6abab6270b..4c03405910 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -33,7 +33,6 @@ exports.up = async function up(knex) { ); table .text('entity_ref') - .unique() .notNullable() .comment('A reference to the entity that the refresh state is tied to'); table @@ -59,13 +58,15 @@ exports.up = async function up(knex) { .notNullable() .comment('JSON array containing all errors related to entity'); table - .dateTime('next_update_at') + .dateTime('next_update_at') // TODO: timezone or change to epoch-millis or similar .notNullable() .comment('Timestamp of when entity should be updated'); table - .dateTime('last_discovery_at') + .dateTime('last_discovery_at') // TODO: timezone or change to epoch-millis or similar .notNullable() .comment('The last timestamp of which this entity was discovered'); + table.unique(['entity_ref'], 'refresh_state_entity_ref_uniq'); + table.index('entity_id', 'refresh_state_entity_id_idx'); table.index('entity_ref', 'refresh_state_entity_ref_idx'); table.index('next_update_at', 'refresh_state_next_update_at_idx'); }); @@ -82,10 +83,18 @@ exports.up = async function up(knex) { .inTable('refresh_state') .onDelete('CASCADE') .comment( - 'Entity ID which correspond to the ID in the refresh_state table', + 'Entity ID which corresponds to the ID in the refresh_state table', ); - table.text('etag').notNullable().comment('Etag to be used for caching'); - table.text('finalized_entity').notNullable().comment('The final entity'); + table + .text('hash') + .notNullable() + .comment( + 'Stable hash of the entity data, to be used for caching and avoiding redundant work', + ); + table + .text('final_entity') + .notNullable() + .comment('The JSON encoded final entity'); table.index('entity_id', 'final_entities_entity_id_idx'); }); @@ -94,38 +103,38 @@ exports.up = async function up(knex) { 'Holds edges between refresh state rows. Every time when an entity is processed and emits another entity, an edge will be stored to represent that fact. This is used to detect orphans and ultimately deletions.', ); table - .text('source_special_key') + .increments('id') + .comment('Primary key to distinguish unique lines from each other'); + table + .text('source_key') .nullable() .comment( 'When the reference source is not an entity, this is an opaque identifier for that source.', ); table - .text('source_entity_id') + .text('source_entity_ref') .nullable() - .references('entity_id') + .references('entity_ref') .inTable('refresh_state') .onDelete('CASCADE') .comment( - 'When the reference source is an entity, this is the ID of the source entity.', + 'When the reference source is an entity, this is the EntityRef of the source entity.', ); table - .text('target_entity_id') + .text('target_entity_ref') .notNullable() - .references('entity_id') + .references('entity_ref') .inTable('refresh_state') .onDelete('CASCADE') - .comment('The ID of the target entity.'); + .comment('The EntityRef of the target entity.'); + table.index('source_key', 'refresh_state_references_source_key_idx'); table.index( - 'source_special_key', - 'refresh_state_references_source_special_key_idx', + 'source_entity_ref', + 'refresh_state_references_source_entity_ref_idx', ); table.index( - 'source_entity_id', - 'refresh_state_references_source_entity_id_idx', - ); - table.index( - 'target_entity_id', - 'refresh_state_references_target_entity_id_idx', + 'target_entity_ref', + 'refresh_state_references_target_entity_ref_idx', ); }); @@ -153,6 +162,29 @@ exports.up = async function up(knex) { table.index('source_entity_ref', 'relations_source_entity_ref_idx'); table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); + + await knex.schema.createTable('search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .text('entity_id') + .references('entity_id') + .inTable('refresh_state') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + table.index(['entity_id'], 'search_entity_id_idx'); + table.index(['key'], 'search_key_idx'); + table.index(['value'], 'search_value_idx'); + }); }; /** @@ -165,6 +197,8 @@ exports.down = async function down(knex) { table.dropIndex([], 'refresh_state_references_target_entity_id_idx'); }); await knex.schema.alterTable('refresh_state', table => { + table.dropUnique([], 'refresh_state_entity_ref_uniq'); + table.dropIndex([], 'refresh_state_entity_id_idx'); table.dropIndex([], 'refresh_state_entity_ref_idx'); table.dropIndex([], 'refresh_state_next_update_at_idx'); }); @@ -175,6 +209,13 @@ exports.down = async function down(knex) { table.index('source_entity_ref', 'relations_source_entity_ref_idx'); table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); + await knex.schema.alterTable('search', table => { + table.dropIndex([], 'search_entity_id_idx'); + table.dropIndex([], 'search_key_idx'); + table.dropIndex([], 'search_value_idx'); + }); + + await knex.schema.dropTable('search'); await knex.schema.dropTable('final_entities'); await knex.schema.dropTable('relations'); await knex.schema.dropTable('references'); diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 5125e9703e..7649067a44 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.7.1", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,13 +30,12 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.3", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", - "@backstage/plugin-search-backend-node": "^0.1.2", + "@backstage/plugin-search-backend-node": "^0.1.4", "@backstage/search-common": "^0.1.1", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", @@ -61,11 +60,10 @@ "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.3", - "zen-observable": "^0.8.15" + "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.9", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", @@ -80,7 +78,6 @@ "files": [ "dist", "migrations/**/*.{js,d.ts}", - "migrationsv2/**/*.{js,d.ts}", "config.d.ts" ], "configSchema": "config.d.ts" diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index aac9a091b8..607581e153 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -88,7 +88,7 @@ describe('readLdapConfig', () => { filter: 'f', paged: true, }, - set: [{ path: 'p', value: 'v' }], + set: { p: 'v' }, map: { rdn: 'u', name: 'v', @@ -107,7 +107,7 @@ describe('readLdapConfig', () => { filter: 'f', paged: true, }, - set: [{ path: 'p', value: 'v' }], + set: { p: 'v' }, map: { rdn: 'u', name: 'v', @@ -136,7 +136,7 @@ describe('readLdapConfig', () => { filter: 'f', paged: true, }, - set: [{ path: 'p', value: 'v' }], + set: { p: 'v' }, map: { rdn: 'u', name: 'v', @@ -155,7 +155,7 @@ describe('readLdapConfig', () => { filter: 'f', paged: true, }, - set: [{ path: 'p', value: 'v' }], + set: { p: 'v' }, map: { rdn: 'u', name: 'v', @@ -182,9 +182,9 @@ describe('readLdapConfig', () => { dn: 'udn', options: { filter: ` - (| - (cn=foo bar) - (cn=bar) + (| + (cn=foo bar) + (cn=bar) ) `, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index 625282ca05..aca02e6e10 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -57,7 +57,7 @@ export type UserConfig = { // default is scope "one" and attributes "*" and "+". options: SearchOptions; // JSON paths (on a.b.c form) and hard coded values to set on those paths - set?: { path: string; value: JsonValue }[]; + set?: { [path: string]: JsonValue }; // Mappings from well known entity fields, to LDAP attribute names map: { // The name of the attribute that holds the relative distinguished name of @@ -94,7 +94,7 @@ export type GroupConfig = { // Only the scope, filter, attributes, and paged fields are supported. options: SearchOptions; // JSON paths (on a.b.c form) and hard coded values to set on those paths - set?: { path: string; value: JsonValue }[]; + set?: { [path: string]: JsonValue }; // Mappings from well known entity fields, to LDAP attribute names map: { // The name of the attribute that holds the relative distinguished name of @@ -189,15 +189,12 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { } function readSetConfig( - c: Config[] | undefined, - ): { path: string; value: JsonValue }[] | undefined { + c: Config | undefined, + ): { [path: string]: JsonValue } | undefined { if (!c) { return undefined; } - return c.map(entry => ({ - path: entry.getString('path'), - value: entry.get('value'), - })); + return Object.fromEntries(c.keys().map(path => [path, c.get(path)])); } function readUserMapConfig( @@ -244,7 +241,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { return { dn: c.getString('dn'), options: readOptionsConfig(c.getOptionalConfig('options')), - set: readSetConfig(c.getOptionalConfigArray('set')), + set: readSetConfig(c.getOptionalConfig('set')), map: readUserMapConfig(c.getOptionalConfig('map')), }; } @@ -255,7 +252,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { return { dn: c.getString('dn'), options: readOptionsConfig(c.getOptionalConfig('options')), - set: readSetConfig(c.getOptionalConfigArray('set')), + set: readSetConfig(c.getOptionalConfig('set')), map: readGroupMapConfig(c.getOptionalConfig('map')), }; } diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts index 3162fbd01c..68542b5b30 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts @@ -15,6 +15,7 @@ */ import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { SearchEntry } from 'ldapjs'; import lodashSet from 'lodash/set'; import { buildOrgHierarchy } from '../util/org'; import { LdapClient } from './client'; @@ -25,7 +26,6 @@ import { LDAP_UUID_ANNOTATION, } from './constants'; import { LdapVendor } from './vendors'; -import { SearchEntry } from 'ldapjs'; /** * Reads users out of an LDAP provider. @@ -63,7 +63,7 @@ export async function readLdapUsers( }; if (set) { - for (const { path, value } of set) { + for (const [path, value] of Object.entries(set)) { lodashSet(entity, path, value); } } @@ -142,7 +142,7 @@ export async function readLdapGroups( }; if (set) { - for (const { path, value } of set) { + for (const [path, value] of Object.entries(set)) { lodashSet(entity, path, value); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts index 4d6f67de22..1d8e139fb3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts @@ -38,7 +38,7 @@ describe('parseEntityYaml', () => { spec: type: website lifecycle: production - owner: guest + owner: user:guest `, 'utf8', ), @@ -60,7 +60,7 @@ describe('parseEntityYaml', () => { spec: { type: 'website', lifecycle: 'production', - owner: 'guest', + owner: 'user:guest', }, }), ]); diff --git a/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts new file mode 100644 index 0000000000..226e5c06a8 --- /dev/null +++ b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolvePackagePath } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import path from 'path'; +import { ConfigLocationEntityProvider } from './ConfigLocationEntityProvider'; +import { EntityProviderConnection } from './types'; + +describe('ConfigLocationEntityProvider', () => { + it('should apply mutation with the correct paths in the config', async () => { + const mockConfig = new ConfigReader({ + catalog: { + locations: [ + { type: 'file', target: './lols.yaml' }, + { type: 'url', target: 'https://github.com/backstage/backstage' }, + ], + }, + }); + + const mockConnection = ({ + applyMutation: jest.fn(), + } as unknown) as EntityProviderConnection; + const locationProvider = new ConfigLocationEntityProvider(mockConfig); + + await locationProvider.connect(mockConnection); + + expect(mockConnection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: path.join( + resolvePackagePath('@backstage/plugin-catalog-backend'), + './lols.yaml', + ), + type: 'file', + }, + }), + ]), + }); + expect(mockConnection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: 'https://github.com/backstage/backstage', + type: 'url', + }, + }), + ]), + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts new file mode 100644 index 0000000000..bccbd0ba41 --- /dev/null +++ b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import path from 'path'; +import { EntityProvider, EntityProviderConnection } from './types'; +import { locationSpecToLocationEntity } from './util'; + +export class ConfigLocationEntityProvider implements EntityProvider { + private connection: EntityProviderConnection | undefined; + + constructor(private readonly config: Config) {} + + getProviderName(): string { + return 'ConfigLocationProvider'; + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection; + + const locationConfigs = + this.config.getOptionalConfigArray('catalog.locations') ?? []; + + const entities = locationConfigs.map(location => { + const type = location.getString('type'); + const target = location.getString('target'); + return locationSpecToLocationEntity({ + type, + target: type === 'file' ? path.resolve(target) : target, + }); + }); + + await this.connection.applyMutation({ + type: 'full', + entities, + }); + } +} diff --git a/plugins/catalog-backend/src/next/Context/BackgroundContext.ts b/plugins/catalog-backend/src/next/Context/BackgroundContext.ts new file mode 100644 index 0000000000..72b9a3b1ed --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/BackgroundContext.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Context, ContextKey } from './types'; + +/** + * A base Context implementation that does not hold any value. + */ +export class BackgroundContext implements Context { + getContextValue(key: ContextKey): T { + return key.defaultValue; + } +} diff --git a/plugins/catalog-backend/src/next/Context/ContextWithValue.ts b/plugins/catalog-backend/src/next/Context/ContextWithValue.ts new file mode 100644 index 0000000000..e8f94fd922 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/ContextWithValue.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Context, ContextKey } from './types'; + +/** + * A Context implementation that holds a single value, optionally extending an existing context. + */ +export class ContextWithValue implements Context { + static create(parent: Context, key: ContextKey, value: unknown) { + return new ContextWithValue(parent, key, value); + } + + private constructor( + private readonly parent: Context, + private readonly key: ContextKey, + private readonly value: unknown, + ) {} + + getContextValue(key: ContextKey): T { + if (this.key === key) { + return this.value as T; + } + return this.parent.getContextValue(key); + } +} diff --git a/plugins/catalog-backend/src/next/Context/TransactionValue.ts b/plugins/catalog-backend/src/next/Context/TransactionValue.ts new file mode 100644 index 0000000000..6959d13a18 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/TransactionValue.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Context, ContextKey } from './types'; +import { Knex } from 'knex'; +import { ContextWithValue } from './ContextWithValue'; + +const transactionContextKey = new ContextKey( + undefined, +); + +/** + * TransactionValue handles the wrapping of a knex transaction in a Context. + */ +export class TransactionValue { + static in(parent: Context, tx: Knex.Transaction) { + return ContextWithValue.create(parent, transactionContextKey, tx); + } + + static from(context: Context): Knex.Transaction { + const transaction = context.getContextValue(transactionContextKey); + if (!transaction) { + throw new Error(`No transaction available in context`); + } + return transaction; + } +} diff --git a/plugins/catalog-backend/src/next/Context/index.ts b/plugins/catalog-backend/src/next/Context/index.ts new file mode 100644 index 0000000000..61dd4a2958 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BackgroundContext } from './BackgroundContext'; +export { ContextWithValue } from './ContextWithValue'; +export { TransactionValue } from './TransactionValue'; +export { ContextKey } from './types'; +export type { Context } from './types'; diff --git a/plugins/catalog-backend/src/next/Context/types.ts b/plugins/catalog-backend/src/next/Context/types.ts new file mode 100644 index 0000000000..0973b83515 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ContextKey { + constructor(readonly defaultValue: T) {} +} + +export interface Context { + getContextValue(key: ContextKey): T; +} diff --git a/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts b/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts deleted file mode 100644 index eaf5a78556..0000000000 --- a/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Observable } from '@backstage/core'; -import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { EntityProvider, LocationStore, EntityMessage } from './types'; -import ObservableImpl from 'zen-observable'; -import { - locationSpecToLocationEntity, - locationSpecToMetadataName, -} from './util'; - -export class DatabaseLocationProvider implements EntityProvider { - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - constructor(private readonly store: LocationStore) { - store.location$().subscribe({ - next: locations => { - if ('all' in locations) { - this.notify({ - all: locations.all.map(l => locationSpecToLocationEntity(l)), - }); - } else { - this.notify({ - added: locations.added.map(l => locationSpecToLocationEntity(l)), - removed: locations.removed.map(l => ({ - kind: 'Location', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: locationSpecToMetadataName(l), - })), - }); - } - }, - }); - } - - private notify(message: EntityMessage) { - for (const subscriber of this.subscribers) { - subscriber.next(message); - } - } - - entityChange$(): Observable { - return new ObservableImpl(subscriber => { - this.store.listLocations().then(locations => { - subscriber.next({ - all: locations.map(l => locationSpecToLocationEntity(l)), - }); - this.subscribers.add(subscriber); - }); - return () => { - this.subscribers.delete(subscriber); - }; - }); - } -} diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 934ab3a1cc..e724fe79d8 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -14,21 +14,47 @@ * limitations under the License. */ -import { Subscription } from '@backstage/core'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Logger } from 'winston'; +import { Stitcher } from './Stitcher'; import { CatalogProcessingEngine, - EntityProvider, - EntityMessage, - ProcessingStateManager, CatalogProcessingOrchestrator, + EntityProvider, + EntityProviderConnection, + EntityProviderMutation, + ProcessingStateManager, } from './types'; -import { Logger } from 'winston'; -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { Stitcher } from './Stitcher'; +class Connection implements EntityProviderConnection { + constructor( + private readonly config: { + stateManager: ProcessingStateManager; + id: string; + }, + ) {} + + async applyMutation(mutation: EntityProviderMutation): Promise { + if (mutation.type === 'full') { + await this.config.stateManager.replaceProcessingItems({ + sourceKey: this.config.id, + type: 'full', + items: mutation.entities, + }); + + return; + } + + await this.config.stateManager.replaceProcessingItems({ + sourceKey: this.config.id, + type: 'delta', + added: mutation.added, + removed: mutation.removed, + }); + } +} export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { - private subscriptions: Subscription[] = []; private running: boolean = false; constructor( @@ -41,11 +67,12 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - const id = 'databaseProvider'; - const subscription = provider - .entityChange$() - .subscribe({ next: m => this.onNext(id, m) }); - this.subscriptions.push(subscription); + await provider.connect( + new Connection({ + stateManager: this.stateManager, + id: provider.getProviderName(), + }), + ); } this.running = true; @@ -54,12 +81,12 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { const { id, entity, - state: intialState, + state: initialState, } = await this.stateManager.getNextProcessingItem(); const result = await this.orchestrator.process({ entity, - state: intialState, + state: initialState, }); for (const error of result.errors) { @@ -77,7 +104,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { state: result.state, errors: result.errors, relations: result.relations, - deferredEntities: result.deferredEntites, + deferredEntities: result.deferredEntities, }); const setOfThingsToStitch = new Set([ @@ -92,30 +119,5 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async stop() { this.running = false; - - for (const subscription of this.subscriptions) { - subscription.unsubscribe(); - } - } - - private async onNext(id: string, message: EntityMessage) { - if ('all' in message) { - // TODO unhandled rejection - await this.stateManager.addProcessingItems({ - id, - type: 'provider', - entities: message.all, - }); - } - - if ('added' in message) { - await this.stateManager.addProcessingItems({ - id, - type: 'provider', - entities: message.added, - }); - - // TODO deletions of message.removed - } } } diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts index 1880b5d17e..8fbb19e536 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts @@ -179,7 +179,7 @@ export class DefaultCatalogProcessingOrchestrator ); } - // Backwards compatible processing of location entites + // Backwards compatible processing of location entities if (isLocationEntity(entity)) { const { type = location.type } = entity.spec; const targets = new Array(); @@ -268,7 +268,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { const errors = new Array(); const relations = new Array(); - const deferredEntites = new Array(); + const deferredEntities = new Array(); const emit = (i: CatalogProcessorResult) => { if (done) { @@ -282,7 +282,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { if (i.type === 'entity') { const originLocation = getEntityOriginLocationRef(parentEntity); - deferredEntites.push({ + deferredEntities.push({ ...i.entity, metadata: { ...i.entity.metadata, @@ -294,7 +294,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { }, }); } else if (i.type === 'location') { - deferredEntites.push( + deferredEntities.push( locationSpecToLocationEntity(i.location, parentEntity), ); } else if (i.type === 'relation') { @@ -311,7 +311,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { return { errors, relations, - deferredEntites, + deferredEntities, }; }, }; diff --git a/plugins/catalog-backend/src/next/DefaultService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts similarity index 100% rename from plugins/catalog-backend/src/next/DefaultService.ts rename to plugins/catalog-backend/src/next/DefaultLocationService.ts diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts new file mode 100644 index 0000000000..f9bb97477c --- /dev/null +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { v4 as uuid } from 'uuid'; +import { DatabaseManager } from './database/DatabaseManager'; +import { DefaultLocationStore } from './DefaultLocationStore'; + +/* eslint-disable */ +xdescribe('DefaultLocationStore', () => { + const createLocationStore = async () => { + const db = await DatabaseManager.createTestDatabase(); + const connection = { applyMutation: jest.fn() }; + const store = new DefaultLocationStore(db); + await store.connect(connection); + return { store, connection }; + }; + + it('should do a full sync with the locations on connect', async () => { + const { connection } = await createLocationStore(); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: [], + }); + }); + + describe('listLocations', () => { + it('lists empty locations when there is no locations', async () => { + const { store } = await createLocationStore(); + + expect(await store.listLocations()).toEqual([]); + }); + + it('lists locations that are added to the db', async () => { + const { store } = await createLocationStore(); + + await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + const listLocations = await store.listLocations(); + + expect(listLocations).toHaveLength(1); + expect(listLocations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }), + ]), + ); + }); + }); + + describe('createLocation', () => { + it('throws when the location already exists', async () => { + const { store } = await createLocationStore(); + const spec = { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }; + await store.createLocation(spec); + + await expect(() => store.createLocation(spec)).rejects.toThrow( + new RegExp(`Location ${spec.type}:${spec.target} already exists`), + ); + }); + + it('calls apply mutation when adding a new location', async () => { + const { store, connection } = await createLocationStore(); + + await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + removed: [], + added: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + ]), + }); + }); + }); + + describe('deleteLocation', () => { + it('throws if the location does not exist', async () => { + const { store } = await createLocationStore(); + + const id = uuid(); + + await expect(() => store.deleteLocation(id)).rejects.toThrow( + new RegExp(`Found no location with ID ${id}`), + ); + }); + + it('calls apply mutation when adding a new location', async () => { + const { store, connection } = await createLocationStore(); + + const location = await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + await store.deleteLocation(location.id); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + added: [], + removed: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + ]), + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index b21fe9da02..f6267df6a5 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -16,27 +16,26 @@ import { LocationSpec, Location } from '@backstage/catalog-model'; import { Database } from '../database'; -import { LocationStore } from './types'; -import { v4 as uuidv4 } from 'uuid'; +import { + LocationStore, + EntityProvider, + EntityProviderConnection, +} from './types'; +import { v4 as uuid } from 'uuid'; +import { locationSpecToLocationEntity } from './util'; import { ConflictError } from '@backstage/errors'; -import { Observable } from '@backstage/core'; -import ObservableImpl from 'zen-observable'; -export type LocationMessage = - | { all: Location[] } - | { added: Location[]; removed: Location[] }; - -export class DefaultLocationStore implements LocationStore { - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); +export class DefaultLocationStore implements LocationStore, EntityProvider { + private _connection: EntityProviderConnection | undefined; constructor(private readonly db: Database) {} - createLocation(spec: LocationSpec): Promise { - return this.db.transaction(async tx => { - // TODO: id should really be type and target combined and not a uuid. + getProviderName(): string { + return 'DefaultLocationStore'; + } + async createLocation(spec: LocationSpec): Promise { + return this.db.transaction(async tx => { // Attempt to find a previous location matching the spec const previousLocations = await this.listLocations(); const previousLocation = previousLocations.some( @@ -49,13 +48,18 @@ export class DefaultLocationStore implements LocationStore { ); } + // TODO: id should really be type and target combined and not a uuid. const location = await this.db.addLocation(tx, { - id: uuidv4(), + id: uuid(), type: spec.type, target: spec.target, }); - this.notifyAddition(location); + await this.connection.applyMutation({ + type: 'delta', + added: [locationSpecToLocationEntity(location)], + removed: [], + }); return location; }); @@ -63,11 +67,17 @@ export class DefaultLocationStore implements LocationStore { async listLocations(): Promise { const dbLocations = await this.db.locations(); - return dbLocations.map(item => ({ - id: item.id, - target: item.target, - type: item.type, - })); + return ( + dbLocations + // TODO(blam): We should create a mutation to remove this location for everyone + // eventually when it's all done and dusted + .filter(({ type }) => type !== 'bootstrap') + .map(item => ({ + id: item.id, + target: item.target, + type: item.type, + })) + ); } getLocation(id: string): Promise { @@ -75,40 +85,38 @@ export class DefaultLocationStore implements LocationStore { } deleteLocation(id: string): Promise { + if (!this.connection) { + throw new Error('location store is not initialized'); + } + return this.db.transaction(async tx => { const location = await this.db.location(id); - if (!location) { - throw new ConflictError(`No location found with id: ${id}`); - } await this.db.removeLocation(tx, id); - this.notifyDeletion(location); + await this.connection.applyMutation({ + type: 'delta', + added: [], + removed: [locationSpecToLocationEntity(location)], + }); }); } - private notifyAddition(location: Location) { - for (const subscriber of this.subscribers) { - subscriber.next({ - added: [location], - removed: [], - }); + private get connection(): EntityProviderConnection { + if (!this._connection) { + throw new Error('location store is not initialized'); } + + return this._connection; } - private notifyDeletion(location: Location) { - for (const subscriber of this.subscribers) { - subscriber.next({ - added: [], - removed: [location], - }); - } - } - - location$(): Observable { - return new ObservableImpl(subscriber => { - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; + async connect(connection: EntityProviderConnection): Promise { + this._connection = connection; + const locations = await this.listLocations(); + const entities = locations.map(location => { + return locationSpecToLocationEntity(location); + }); + await this.connection.applyMutation({ + type: 'full', + entities, }); } } diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index 91090bc139..9c1d20ae9c 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -14,17 +14,25 @@ * limitations under the License. */ -import { ProcessingDatabase, RefreshStateItem } from './database/types'; +import { ProcessingDatabase } from './database/types'; import { - AddProcessingItemRequest, - ProccessingItem, + ProcessingItem, ProcessingItemResult, ProcessingStateManager, + ReplaceProcessingItemsRequest, } from './types'; export class DefaultProcessingStateManager implements ProcessingStateManager { constructor(private readonly db: ProcessingDatabase) {} + replaceProcessingItems( + request: ReplaceProcessingItemsRequest, + ): Promise { + return this.db.transaction(async tx => { + await this.db.replaceUnprocessedEntities(tx, request); + }); + } + async setProcessingItemResult(result: ProcessingItemResult) { return this.db.transaction(async tx => { await this.db.updateProcessedEntity(tx, { @@ -38,37 +46,24 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { }); } - async addProcessingItems(request: AddProcessingItemRequest) { - return this.db.transaction(async tx => { - await this.db.addUnprocessedEntities(tx, request); - }); - } - - async getNextProcessingItem(): Promise { - const entities = await new Promise(resolve => - this.popFromQueue(resolve), - ); - const { id, state, unprocessedEntity } = entities[0]; - return { - id, - entity: unprocessedEntity, - state, - }; - } - - async popFromQueue(resolve: (rows: RefreshStateItem[]) => void) { - const entities = await this.db.transaction(async tx => { - return this.db.getProcessableEntities(tx, { - processBatchSize: 1, + async getNextProcessingItem(): Promise { + for (;;) { + const { items } = await this.db.transaction(async tx => { + return this.db.getProcessableEntities(tx, { + processBatchSize: 1, + }); }); - }); - // No entities require refresh, wait and try again. - if (!entities.items.length) { - setTimeout(() => this.popFromQueue(resolve), 1000); - return; + if (items.length) { + const { id, state, unprocessedEntity } = items[0]; + return { + id, + entity: unprocessedEntity, + state, + }; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); } - - resolve(entities.items); } } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index c3e0a6bd9c..10500fdc15 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -19,7 +19,6 @@ import { resolvePackagePath, UrlReader, } from '@backstage/backend-common'; -import fs from 'fs-extra'; import { DefaultNamespaceEntityPolicy, EntityPolicies, @@ -39,6 +38,7 @@ import { EntitiesCatalog, LocationsCatalog, } from '../catalog'; +import { CommonDatabase } from '../database/CommonDatabase'; import { AnnotateLocationEntityProcessor, BitbucketDiscoveryProcessor, @@ -53,7 +53,6 @@ import { MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, - StaticLocationProcessor, UrlReaderProcessor, } from '../ingestion'; import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; @@ -64,16 +63,15 @@ import { } from '../ingestion/processors/PlaceholderProcessor'; import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; import { LocationAnalyzer } from '../ingestion/types'; +import { CatalogProcessingEngine } from '../next/types'; +import { ConfigLocationEntityProvider } from './ConfigLocationEntityProvider'; +import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; import { DefaultCatalogProcessingEngine } from './DefaultCatalogProcessingEngine'; import { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator'; -import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; -import { DatabaseLocationProvider } from '../next/DatabaseLocationProvider'; import { DefaultLocationStore } from './DefaultLocationStore'; import { DefaultProcessingStateManager } from './DefaultProcessingStateManager'; -import { CatalogProcessingEngine } from '../next/types'; import { NextEntitiesCatalog } from './NextEntitiesCatalog'; import { Stitcher } from './Stitcher'; -import { CommonDatabase } from '../database/CommonDatabase'; export type CatalogEnvironment = { logger: Logger; @@ -244,19 +242,13 @@ export class NextCatalogBuilder { const parser = this.parser || defaultEntityDataParser; const dbClient = await database.getClient(); - const allMigrations = resolvePackagePath( - '@backstage/plugin-catalog-backend', - 'migrations', - ); - - const migrationsDir = resolvePackagePath( - '@backstage/plugin-catalog-backend', - 'migrationsv2', - ); - await fs.copy(allMigrations, migrationsDir); await dbClient.migrate.latest({ - directory: migrationsDir, + directory: resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrationsv2', + ), }); + const db = new CommonDatabase(dbClient, logger); const processingDatabase = new DefaultProcessingDatabase(dbClient, logger); @@ -272,11 +264,11 @@ export class NextCatalogBuilder { const entitiesCatalog = new NextEntitiesCatalog(dbClient); const locationStore = new DefaultLocationStore(db); - const dbLocationProvider = new DatabaseLocationProvider(locationStore); const stitcher = new Stitcher(dbClient, logger); + const configLocationProvider = new ConfigLocationEntityProvider(config); const processingEngine = new DefaultCatalogProcessingEngine( logger, - [dbLocationProvider], // entityproviders + [locationStore, configLocationProvider], stateManager, orchestrator, stitcher, @@ -324,7 +316,6 @@ export class NextCatalogBuilder { // These are always there no matter what const processors: CatalogProcessor[] = [ - StaticLocationProcessor.fromConfig(config), new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }), new BuiltinKindsEntityProcessor(), ]; @@ -340,7 +331,6 @@ export class NextCatalogBuilder { MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), CodeOwnersProcessor.fromConfig(config, { logger, reader }), - // new LocationEntityProcessor({ integrations }), new AnnotateLocationEntityProcessor({ integrations }), ); } diff --git a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts index 5bddd47f41..e87bb01e1f 100644 --- a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts @@ -37,7 +37,7 @@ export class NextEntitiesCatalog implements EntitiesCatalog { 'final_entities', ).select(); - const entities = dbResponse.map(e => JSON.parse(e.finalized_entity)); + const entities = dbResponse.map(e => JSON.parse(e.final_entity)); return { entities, diff --git a/plugins/catalog-backend/src/next/Stitcher.test.ts b/plugins/catalog-backend/src/next/Stitcher.test.ts new file mode 100644 index 0000000000..f38af384fa --- /dev/null +++ b/plugins/catalog-backend/src/next/Stitcher.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DatabaseManager } from './database/DatabaseManager'; +import { + DbRefreshStateReferencesRow, + DbRefreshStateRow, + DbRelationsRow, +} from './database/DefaultProcessingDatabase'; +import { DbSearchRow } from './search'; +import { DbFinalEntitiesRow, Stitcher } from './Stitcher'; + +describe('Stitcher', () => { + let db: Knex; + const logger = getVoidLogger(); + + beforeEach(async () => { + db = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(db); + }); + + it('runs the happy path', async () => { + const stitcher = new Stitcher(db, logger); + + await db.transaction(async tx => { + await tx('refresh_state').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + unprocessed_entity: JSON.stringify({}), + processed_entity: JSON.stringify({ + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + }, + spec: { + k: 'v', + }, + }), + errors: '[]', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + }, + ]); + await tx('refresh_state_references').insert([ + { source_key: 'a', target_entity_ref: 'k:ns/n' }, + ]); + await tx('relations').insert([ + { + originating_entity_id: 'my-id', + source_entity_ref: 'k:ns/n', + type: 'looksAt', + target_entity_ref: 'k:ns/other', + }, + ]); + }); + + await stitcher.stitch(new Set(['k:ns/n'])); + + let firstHash: string; + await db.transaction(async tx => { + const entities = await tx('final_entities'); + + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].final_entity); + expect(entity).toEqual({ + relations: [ + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'other', + }, + }, + ], + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + etag: expect.any(String), + generation: 1, + uid: 'my-id', + }, + spec: { + k: 'v', + }, + }); + + expect(entity.metadata.etag).toEqual(entities[0].hash); + firstHash = entities[0].hash; + + const search = await tx('search'); + expect(search).toEqual( + expect.arrayContaining([ + { entity_id: 'my-id', key: 'relations.looksat', value: 'k:ns/other' }, + { entity_id: 'my-id', key: 'apiversion', value: 'a' }, + { entity_id: 'my-id', key: 'kind', value: 'k' }, + { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, + { entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' }, + { entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' }, + { entity_id: 'my-id', key: 'spec.k', value: 'v' }, + ]), + ); + }); + + // Re-stitch without any changes + await stitcher.stitch(new Set(['k:ns/n'])); + + await db.transaction(async tx => { + const entities = await tx('final_entities'); + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].final_entity); + expect(entities[0].hash).toEqual(firstHash); + expect(entity.metadata.etag).toEqual(firstHash); + }); + + // Now add one more relation and re-stitch + await db.transaction(async tx => { + await tx('relations').insert([ + { + originating_entity_id: 'my-id', + source_entity_ref: 'k:ns/n', + type: 'looksAt', + target_entity_ref: 'k:ns/third', + }, + ]); + }); + + await stitcher.stitch(new Set(['k:ns/n'])); + + await db.transaction(async tx => { + const entities = await tx('final_entities'); + + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].final_entity); + expect(entity).toEqual({ + relations: expect.arrayContaining([ + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'other', + }, + }, + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'third', + }, + }, + ]), + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + etag: expect.any(String), + generation: 1, + uid: 'my-id', + }, + spec: { + k: 'v', + }, + }); + + expect(entities[0].hash).not.toEqual(firstHash); + expect(entities[0].hash).toEqual(entity.metadata.etag); + + const search = await tx('search'); + expect(search).toEqual( + expect.arrayContaining([ + { entity_id: 'my-id', key: 'relations.looksat', value: 'k:ns/other' }, + { entity_id: 'my-id', key: 'relations.looksat', value: 'k:ns/third' }, + { entity_id: 'my-id', key: 'apiversion', value: 'a' }, + { entity_id: 'my-id', key: 'kind', value: 'k' }, + { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, + { entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' }, + { entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' }, + { entity_id: 'my-id', key: 'spec.k', value: 'v' }, + ]), + ); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 3e5e3ac24b..bdc028d923 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -14,26 +14,28 @@ * limitations under the License. */ +import { Entity, parseEntityRef } from '@backstage/catalog-model'; +import { ConflictError } from '@backstage/errors'; +import { createHash } from 'crypto'; +import stableStringify from 'fast-json-stable-stringify'; import { Knex } from 'knex'; import { Logger } from 'winston'; import { Transaction } from '../database'; -import { ConflictError } from '@backstage/errors'; -import { - DbRefreshStateReferences, - DbRefreshStateRow, - DbRelationsRow, -} from './database/DefaultProcessingDatabase'; -import { Entity, parseEntityRef } from '@backstage/catalog-model'; -import { createHash } from 'crypto'; -import stableStringify from 'fast-json-stable-stringify'; +import { buildEntitySearch, DbSearchRow } from './search'; + +// The number of items that are sent per batch to the database layer, when +// doing .batchInsert calls to knex. This needs to be low enough to not cause +// errors in the underlying engine due to exceeding query limits, but large +// enough to get the speed benefits. +const BATCH_SIZE = 50; export type DbFinalEntitiesRow = { entity_id: string; - etag: string; - finalized_entity: string; + hash: string; + final_entity: string; }; -function generateEntityEtag(entity: Entity) { +function generateStableHash(entity: Entity) { return createHash('sha1') .update(stableStringify({ ...entity })) .digest('hex'); @@ -49,64 +51,129 @@ export class Stitcher { for (const entityRef of entityRefs) { await this.transaction(async txOpaque => { const tx = txOpaque as Knex.Transaction; - const [result] = await tx('refresh_state') - .select('entity_id', 'processed_entity') - .where({ entity_ref: entityRef }); - if (!result) { + // Selecting from refresh_state and final_entities should yield exactly + // one row (except in abnormal cases where the stitch was invoked for + // something that didn't exist at all, in which case it's zero rows). + // The join with the temporary incoming_references still gives one row. + // The only result set "expanding" join is the one with relations, so + // the output should be at least one row (if zero or one relations were + // found), or at most the same number of rows as relations. + const result: Array<{ + entityId: string; + processedEntity?: string; + errors: string; + incomingReferenceCount: string | number; + previousHash?: string; + relationType?: string; + relationTarget?: string; + }> = await tx + .with('incoming_references', function incomingReferences(builder) { + return builder + .from('refresh_state_references') + .where({ target_entity_ref: entityRef }) + .count({ count: '*' }); + }) + .select({ + entityId: 'refresh_state.entity_id', + processedEntity: 'refresh_state.processed_entity', + errors: 'refresh_state.errors', + incomingReferenceCount: 'incoming_references.count', + previousHash: 'final_entities.hash', + relationType: 'relations.type', + relationTarget: 'relations.target_entity_ref', + }) + .from('refresh_state') + .leftJoin('incoming_references', {}) + .leftOuterJoin('final_entities', { + 'final_entities.entity_id': 'refresh_state.entity_id', + }) + .leftOuterJoin('relations', { + 'relations.source_entity_ref': 'refresh_state.entity_ref', + }) + .where({ 'refresh_state.entity_ref': entityRef }) + .orderBy('relationType', 'asc') + .orderBy('relationTarget', 'asc'); + + // If there were no rows returned, it would mean that there was no + // matching row even in the refresh_state. This can happen for example + // if we emit a relation to something that hasn't been ingested yet. + // It's safe to ignore this stitch attempt in that case. + if (!result.length) { this.logger.debug( `Unable to stitch ${entityRef}, item does not exist in refresh state table`, ); return; - } else if (!result.processed_entity) { + } + + const { + entityId, + processedEntity, + // errors, + incomingReferenceCount, + previousHash, + } = result[0]; + + // If there was no processed entity in place, the target hasn't been + // through the processing steps yet. It's safe to ignore this stitch + // attempt in that case, since another stitch will be triggered when + // that processing has finished. + if (!processedEntity) { this.logger.debug( `Unable to stitch ${entityRef}, the entity has not yet been processed`, ); return; } - const entity: Entity = JSON.parse(result.processed_entity); + // Grab the processed entity and stitch all of the relevant data into + // it + const entity = JSON.parse(processedEntity) as Entity; + const isOrphan = Number(incomingReferenceCount) === 0; - const entityId = entity?.metadata?.uid; - if (!entityId) { - this.logger.error(`missing ID in entity ${JSON.stringify(entity)}`); - return; - } - - const [reference_count_result] = await tx( - 'refresh_state_references', - ) - .where({ target_entity_id: entity.metadata.uid }) - .count({ reference_count: 'target_entity_id' }); - - if (Number(reference_count_result.reference_count) === 0) { - this.logger.debug(`${entityRef} is orphan`); + if (isOrphan) { + this.logger.debug(`${entityRef} is an orphan`); entity.metadata.annotations = { ...entity.metadata.annotations, ['backstage.io/orphan']: 'true', }; } - const relationResults = await tx('relations') - .where({ source_entity_ref: entityRef }) - .select(); + // TODO: entityRef is lower case and should be uppercase in the final + // result + entity.relations = result + .filter(row => row.relationType /* exclude null row, if relevant */) + .map(row => ({ + type: row.relationType!, + target: parseEntityRef(row.relationTarget!), + })); - // TODO: entityRef is lower case and should be uppercase in the final result. - entity.relations = relationResults.map(relation => ({ - type: relation.type, - target: parseEntityRef(relation.target_entity_ref), - })); + // If the output entity was actually not changed, just abort + const hash = generateStableHash(entity); + if (hash === previousHash) { + this.logger.debug(`Skipped stitching of ${entityRef}, no changes`); + return; + } + + entity.metadata.uid = entityId; entity.metadata.generation = 1; - const etag = generateEntityEtag(entity); - entity.metadata.etag = etag; + if (!entity.metadata.etag) { + // If the original data source did not have its own etag handling, + // use the hash as a good-quality etag + entity.metadata.etag = hash; + } + await tx('final_entities') .insert({ - finalized_entity: JSON.stringify(entity), entity_id: entityId, - etag, + final_entity: JSON.stringify(entity), + hash, }) .onConflict('entity_id') - .merge(['finalized_entity', 'etag']); + .merge(['final_entity', 'hash']); + + const searchEntries = buildEntitySearch(entityId, entity); + await tx('search').where({ entity_id: entityId }).delete(); + await tx.batchInsert('search', searchEntries, BATCH_SIZE); }); } } diff --git a/plugins/catalog-backend/src/next/database/DatabaseManager.ts b/plugins/catalog-backend/src/next/database/DatabaseManager.ts new file mode 100644 index 0000000000..22ae4e782f --- /dev/null +++ b/plugins/catalog-backend/src/next/database/DatabaseManager.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; +import knexFactory, { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import { Logger } from 'winston'; +import { CommonDatabase } from '../../database/CommonDatabase'; +import { Database } from '../../database/types'; + +export type CreateDatabaseOptions = { + logger: Logger; +}; + +const defaultOptions: CreateDatabaseOptions = { + logger: getVoidLogger(), +}; + +export class DatabaseManager { + public static async createDatabase( + knex: Knex, + options: Partial = {}, + ): Promise { + const migrationsDir = resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrationsv2', + ); + + await knex.migrate.latest({ + directory: migrationsDir, + }); + const { logger } = { ...defaultOptions, ...options }; + return new CommonDatabase(knex, logger); + } + + public static async createInMemoryDatabase(): Promise { + const knex = await this.createInMemoryDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createInMemoryDatabaseConnection(): Promise { + const knex = knexFactory({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + } + + public static async createTestDatabase(): Promise { + const knex = await this.createTestDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createTestDatabaseConnection(): Promise { + const config: Knex.Config = { + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }; + + let knex = knexFactory(config); + if (typeof config.connection !== 'string') { + const tempDbName = `d${uuid().replace(/-/g, '')}`; + await knex.raw(`CREATE DATABASE ${tempDbName};`); + knex = knexFactory({ + ...config, + connection: { + ...config.connection, + database: tempDbName, + }, + }); + } + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + } +} diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts new file mode 100644 index 0000000000..d76bd74260 --- /dev/null +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -0,0 +1,517 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// import { DefaultProcessingDatabase } from './DefaultProcessingDatabase'; +import { DatabaseManager } from './DatabaseManager'; +import { Knex } from 'knex'; +import { + DbRefreshStateReferencesRow, + DbRefreshStateRow, + DbRelationsRow, + DefaultProcessingDatabase, +} from './DefaultProcessingDatabase'; + +import { Entity, EntityRelationSpec } from '@backstage/catalog-model'; +import * as uuid from 'uuid'; +import { getVoidLogger } from '@backstage/backend-common'; + +describe('Default Processing Database', () => { + let db: Knex; + let processingDatabase: DefaultProcessingDatabase; + const logger = getVoidLogger(); + + beforeEach(async () => { + db = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(db); + + processingDatabase = new DefaultProcessingDatabase(db, logger); + }); + + describe('replaceUnprocessedEntities', () => { + const insertRefRow = async (ref: DbRefreshStateReferencesRow) => { + return db('refresh_state_references').insert( + ref, + ); + }; + + const insertRefreshStateRow = async (ref: DbRefreshStateRow) => { + await db('refresh_state').insert(ref); + }; + + const createLocations = async (entityRefs: string[]) => { + for (const ref of entityRefs) { + await insertRefreshStateRow({ + entity_id: uuid.v4(), + entity_ref: ref, + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '', + next_update_at: 'now()', + last_discovery_at: 'now()', + }); + } + }; + + it('replaces all existing state correctly for simple dependency chains', async () => { + /* + config -> location:default/root -> location:default/root-1 -> location:default/root-2 + database -> location:default/second -> location:default/root-2 + */ + await createLocations([ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-2', + 'location:default/second', + ]); + + await insertRefRow({ + source_key: 'config', + target_entity_ref: 'location:default/root', + }); + + await insertRefRow({ + source_key: 'database', + target_entity_ref: 'location:default/second', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'location:default/root-2', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/second', + target_entity_ref: 'location:default/root-2', + }); + + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config', + items: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db( + 'refresh_state', + ).select(); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + for (const ref of ['location:default/root', 'location:default/root-1']) { + expect(currentRefreshState.some(t => t.entity_ref === ref)).toBeFalsy(); + } + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.target_entity_ref === 'location:default/root-1' && + t.source_key === 'config', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.target_entity_ref === 'location:default/new-root' && + t.source_key === 'config', + ), + ).toBeTruthy(); + }); + + it('should work for more complex chains', async () => { + /* + config -> location:default/root -> location:default/root-1 -> location:default/root-2 + config -> location:default/root -> location:default/root-1a -> location:default/root-2 + */ + await createLocations([ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-2', + 'location:default/root-1a', + ]); + + await insertRefRow({ + source_key: 'config', + target_entity_ref: 'location:default/root', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1a', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'location:default/root-2', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1a', + target_entity_ref: 'location:default/root-2', + }); + + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config', + items: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db( + 'refresh_state', + ).select(); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + const deletedRefs = [ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-1a', + 'location:default/root-2', + ]; + + for (const ref of deletedRefs) { + expect(currentRefreshState.some(t => t.entity_ref === ref)).toBeFalsy(); + } + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'config' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'config' && + t.target_entity_ref === 'location:default/root', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1a', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1a' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + }); + + it('should add new locations using the delta options', async () => { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'delta', + sourceKey: 'lols', + removed: [], + added: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db( + 'refresh_state', + ).select(); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'lols' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + }); + + it('should not remove locations that are referenced elsewhere', async () => { + /* + config-1 -> location:default/root + config-2 -> location:default/root + */ + await createLocations(['location:default/root']); + + await insertRefRow({ + source_key: 'config-1', + target_entity_ref: 'location:default/root', + }); + await insertRefRow({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }); + + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config-1', + items: [], + }); + }); + + const currentRefreshState = await db( + 'refresh_state', + ).select(); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + expect(currentRefRowState).toEqual([ + expect.objectContaining({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }), + ]); + + expect(currentRefreshState).toEqual([ + expect.objectContaining({ + entity_ref: 'location:default/root', + }), + ]); + }); + + it('should remove old locations using the delta options', async () => { + await createLocations(['location:default/new-root']); + + await insertRefRow({ + source_key: 'lols', + target_entity_ref: 'location:default/new-root', + }); + + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'delta', + sourceKey: 'lols', + added: [], + removed: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db( + 'refresh_state', + ).select(); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'lols' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeFalsy(); + }); + }); + + describe('updateProcessedEntity', () => { + it('should throw if the entity does not exist', async () => { + await processingDatabase.transaction(async tx => { + await expect( + processingDatabase.updateProcessedEntity(tx, { + id: '9', + processedEntity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + deferredEntities: [], + relations: [], + }), + ).rejects.toThrow('Processing state not found for 9'); + }); + }); + + it('should update a processed entity', async () => { + await db('refresh_state').insert({ + entity_id: '321', + entity_ref: 'location:default/new-root', + unprocessed_entity: '', + errors: '', + next_update_at: 'now()', + last_discovery_at: 'now()', + }); + + const deferredEntity = { + apiVersion: '1.0.0', + metadata: { + name: 'deferred', + }, + kind: 'Location', + } as Entity; + + const relation: EntityRelationSpec = { + source: { + kind: 'Component', + namespace: 'Default', + name: 'foo', + }, + target: { + kind: 'Component', + namespace: 'Default', + name: 'foo', + }, + type: 'url', + }; + + await processingDatabase.transaction(async tx => { + await processingDatabase.updateProcessedEntity(tx, { + id: '321', + processedEntity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + deferredEntities: [deferredEntity], + relations: [relation], + }); + }); + + const deferredResult = await db('refresh_state') + .where({ entity_ref: 'location:default/deferred' }) + .select(); + expect(deferredResult.length).toBe(1); + + const [relations] = await db('relations') + .where({ originating_entity_id: '321' }) + .select(); + expect(relations).toEqual({ + originating_entity_id: '321', + source_entity_ref: 'component:default/foo', + type: 'url', + target_entity_ref: 'component:default/foo', + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index cf2d4e121c..dc5e6036f5 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -15,6 +15,7 @@ */ import { ConflictError, NotFoundError } from '@backstage/errors'; +import { stringifyEntityRef, Entity } from '@backstage/catalog-model'; import { Knex } from 'knex'; import { Transaction } from '../../database'; import lodash from 'lodash'; @@ -24,20 +25,23 @@ import { AddUnprocessedEntitiesOptions, UpdateProcessedEntityOptions, GetProcessableEntitiesResult, + ReplaceUnprocessedEntitiesOptions, + RefreshStateItem, } from './types'; import type { Logger } from 'winston'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; + import { v4 as uuid } from 'uuid'; +import { JsonObject } from '@backstage/config'; export type DbRefreshStateRow = { entity_id: string; entity_ref: string; unprocessed_entity: string; - processed_entity: string; - cache: string; + processed_entity?: string; + cache?: string; next_update_at: string; last_discovery_at: string; // remove? - errors: string; + errors?: string; }; export type DbRelationsRow = { @@ -47,10 +51,10 @@ export type DbRelationsRow = { type: string; }; -export type DbRefreshStateReferences = { - source_special_key?: string; - source_entity_id?: string; - target_entity_id: string; +export type DbRefreshStateReferencesRow = { + source_key?: string; + source_entity_ref?: string; + target_entity_ref: string; }; // The number of items that are sent per batch to the database layer, when @@ -86,7 +90,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { errors, }) .where('entity_id', id); - if (refreshResult === 0) { throw new NotFoundError(`Processing state not found for ${id}`); } @@ -94,8 +97,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { // Schedule all deferred entities for future processing. await this.addUnprocessedEntities(tx, { entities: deferredEntities, - id, - type: 'entity', + entityRef: stringifyEntityRef(processedEntity), }); // Update fragments @@ -128,49 +130,230 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + private async createDelta( + tx: Knex.Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise<{ toAdd: Entity[]; toRemove: string[] }> { + if (options.type === 'delta') { + return { + toAdd: options.added, + toRemove: options.removed.map(e => stringifyEntityRef(e)), + }; + } + + const oldRefs = await tx( + 'refresh_state_references', + ) + .where({ source_key: options.sourceKey }) + .select('target_entity_ref') + .then(rows => rows.map(r => r.target_entity_ref)); + + const items = options.items.map(entity => ({ + entity, + ref: stringifyEntityRef(entity), + })); + + const oldRefsSet = new Set(oldRefs); + const newRefsSet = new Set(items.map(item => item.ref)); + const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); + const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); + + return { toAdd: toAdd.map(({ entity }) => entity), toRemove }; + } + + async replaceUnprocessedEntities( + txOpaque: Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const { toAdd, toRemove } = await this.createDelta(tx, options); + + if (toRemove.length) { + // TODO(freben): Batch split, to not hit variable limits? + /* + WITH RECURSIVE + -- All the nodes that can be reached downwards from our root + descendants(root_id, entity_ref) AS ( + SELECT id, target_entity_ref + FROM refresh_state_references + WHERE source_key = "R1" AND target_entity_ref = "A" + UNION + SELECT descendants.root_id, target_entity_ref + FROM descendants + JOIN refresh_state_references ON source_entity_ref = descendants.entity_ref + ), + -- All the nodes that can be reached upwards from the descendants + ancestors(root_id, via_entity_ref, to_entity_ref) AS ( + SELECT NULL, entity_ref, entity_ref + FROM descendants + UNION + SELECT + CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END, + source_entity_ref, + ancestors.to_entity_ref + FROM ancestors + JOIN refresh_state_references ON target_entity_ref = ancestors.via_entity_ref + ) + -- Start out with all of the descendants + SELECT descendants.entity_ref + FROM descendants + -- Expand with all ancestors that point to those, but aren't the current root + LEFT OUTER JOIN ancestors + ON ancestors.to_entity_ref = descendants.entity_ref + AND ancestors.root_id IS NOT NULL + AND ancestors.root_id != descendants.root_id + -- Exclude all lines that had such a foreign ancestor + WHERE ancestors.root_id IS NULL; + */ + const removedCount = await tx('refresh_state') + .whereIn('entity_ref', function orphanedEntityRefs(orphans) { + return ( + orphans + // All the nodes that can be reached downwards from our root + .withRecursive('descendants', function descendants(outer) { + return outer + .select({ root_id: 'id', entity_ref: 'target_entity_ref' }) + .from('refresh_state_references') + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .union(function recursive(inner) { + return inner + .select({ + root_id: 'descendants.root_id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('descendants') + .join('refresh_state_references', { + 'descendants.entity_ref': + 'refresh_state_references.source_entity_ref', + }); + }); + }) + // All the nodes that can be reached upwards from the descendants + .withRecursive('ancestors', function ancestors(outer) { + return outer + .select({ + root_id: tx.raw('NULL', []), + via_entity_ref: 'entity_ref', + to_entity_ref: 'entity_ref', + }) + .from('descendants') + .union(function recursive(inner) { + return inner + .select({ + root_id: tx.raw( + 'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END', + [], + ), + via_entity_ref: 'source_entity_ref', + to_entity_ref: 'ancestors.to_entity_ref', + }) + .from('ancestors') + .join('refresh_state_references', { + target_entity_ref: 'ancestors.via_entity_ref', + }); + }); + }) + // Start out with all of the descendants + .select('descendants.entity_ref') + .from('descendants') + // Expand with all ancestors that point to those, but aren't the current root + .leftOuterJoin('ancestors', function keepaliveRoots() { + this.on( + 'ancestors.to_entity_ref', + '=', + 'descendants.entity_ref', + ); + this.andOnNotNull('ancestors.root_id'); + this.andOn('ancestors.root_id', '!=', 'descendants.root_id'); + }) + .whereNull('ancestors.root_id') + ); + }) + .delete(); + + await tx('refresh_state_references') + .where('source_key', '=', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .delete(); + + this.logger.debug( + `removed, ${removedCount} entities: ${JSON.stringify(toRemove)}`, + ); + } + + if (toAdd.length) { + const state: Knex.DbRecord[] = toAdd.map(entity => ({ + entity_id: uuid(), + entity_ref: stringifyEntityRef(entity), + unprocessed_entity: JSON.stringify(entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + })); + + const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( + entity => ({ + source_key: options.sourceKey, + target_entity_ref: stringifyEntityRef(entity), + }), + ); + // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense + await tx.batchInsert('refresh_state', state, BATCH_SIZE); + await tx.batchInsert( + 'refresh_state_references', + stateReferences, + BATCH_SIZE, + ); + } + } + async addUnprocessedEntities( txOpaque: Transaction, options: AddUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - const entityIds = new Array(); - for (const entity of options.entities) { - const entityRef = stringifyEntityRef(entity); - await tx('refresh_state') - .insert({ + const stateRows = options.entities.map( + entity => + ({ entity_id: uuid(), - entity_ref: entityRef, + entity_ref: stringifyEntityRef(entity), unprocessed_entity: JSON.stringify(entity), errors: '', next_update_at: tx.fn.now(), last_discovery_at: tx.fn.now(), - }) + } as Knex.DbRecord), + ); + const stateReferenceRows = stateRows.map( + stateRow => + ({ + source_entity_ref: options.entityRef, + target_entity_ref: stateRow.entity_ref, + } as Knex.DbRecord), + ); + + // Upsert all of the unprocessed entities into the refresh_state table, by + // their entity ref. + // TODO(freben): Can this be batched somehow? + for (const row of stateRows) { + await tx('refresh_state') + .insert(row) .onConflict('entity_ref') .merge(['unprocessed_entity', 'last_discovery_at']); - - const [{ entity_id: entityId }] = await tx( - 'refresh_state', - ).where({ entity_ref: entityRef }); - entityIds.push(entityId); } - const key = - options.type === 'provider' - ? { source_special_key: options.id } - : { source_entity_id: options.id }; - // copied from update refs - await tx('refresh_state_references') - .where(key) + // Replace all references for the originating entity before creating new ones + await tx('refresh_state_references') + .where({ source_entity_ref: options.entityRef }) .delete(); - - const referenceRows: DbRefreshStateReferences[] = entityIds.map( - entityId => ({ - ...key, - target_entity_id: entityId, - }), + await tx.batchInsert( + 'refresh_state_references', + stateReferenceRows, + BATCH_SIZE, ); - await tx.batchInsert('refresh_state_references', referenceRows, BATCH_SIZE); } async getProcessableEntities( @@ -198,16 +381,23 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { }); return { - items: items.map(i => ({ - id: i.entity_id, - entityRef: i.entity_ref, - unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity, - processedEntity: JSON.parse(i.processed_entity) as Entity, - nextUpdateAt: i.next_update_at, - lastDiscoveryAt: i.last_discovery_at, - state: JSON.parse(i.cache), - errors: i.errors, - })), + items: items.map( + i => + ({ + id: i.entity_id, + entityRef: i.entity_ref, + unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity, + processedEntity: i.processed_entity + ? (JSON.parse(i.processed_entity) as Entity) + : undefined, + nextUpdateAt: i.next_update_at, + lastDiscoveryAt: i.last_discovery_at, + state: i.cache + ? JSON.parse(i.cache) + : new Map(), + errors: i.errors, + } as RefreshStateItem), + ), }; } diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index 22e9e90c8e..d7a1dcd20b 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -19,8 +19,7 @@ import { JsonObject } from '@backstage/config'; import { Transaction } from '../../database/types'; export type AddUnprocessedEntitiesOptions = { - type: 'entity' | 'provider'; - id: string; + entityRef: string; entities: Entity[]; }; @@ -39,17 +38,30 @@ export type RefreshStateItem = { id: string; entityRef: string; unprocessedEntity: Entity; - processedEntity: Entity; + processedEntity?: Entity; nextUpdateAt: string; lastDiscoveryAt: string; // remove? state: Map; - errors: string; + errors?: string; }; export type GetProcessableEntitiesResult = { items: RefreshStateItem[]; }; +export type ReplaceUnprocessedEntitiesOptions = + | { + sourceKey: string; + items: Entity[]; + type: 'full'; + } + | { + sourceKey: string; + added: Entity[]; + removed: Entity[]; + type: 'delta'; + }; + export interface ProcessingDatabase { transaction(fn: (tx: Transaction) => Promise): Promise; @@ -58,6 +70,10 @@ export interface ProcessingDatabase { options: AddUnprocessedEntitiesOptions, ): Promise; + replaceUnprocessedEntities( + txOpaque: Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise; getProcessableEntities( txOpaque: Transaction, request: { processBatchSize: number }, diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts new file mode 100644 index 0000000000..2e0b07b4a9 --- /dev/null +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { buildEntitySearch, mapToRows, traverse } from './search'; + +describe('search', () => { + describe('traverse', () => { + it('expands lists of strings to several rows', () => { + const input = { a: ['b', 'c', 'd'] }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a', value: 'b' }, + { key: 'a.b', value: true }, + { key: 'a', value: 'c' }, + { key: 'a.c', value: true }, + { key: 'a', value: 'd' }, + { key: 'a.d', value: true }, + ]); + }); + + it('expands objects', () => { + const input = { a: { b: { c: 'd' }, e: 'f' } }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a.b.c', value: 'd' }, + { key: 'a.e', value: 'f' }, + ]); + }); + + it('expands list of objects', () => { + const input = { root: { list: [{ a: 1 }, { a: 2 }] } }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'root.list.a', value: 1 }, + { key: 'root.list.a', value: 2 }, + ]); + }); + + it('skips over special keys', () => { + const input = { + status: { x: 1 }, + attachments: [{ y: 2 }], + relations: [{ z: 3 }], + a: 'a', + metadata: { + b: 'b', + name: 'name', + namespace: 'namespace', + uid: 'uid', + etag: 'etag', + generation: 'generation', + c: 'c', + }, + d: 'd', + }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a', value: 'a' }, + { key: 'metadata.b', value: 'b' }, + { key: 'metadata.c', value: 'c' }, + { key: 'd', value: 'd' }, + ]); + }); + }); + + describe('mapToRows', () => { + it('converts base types to strings or null', () => { + const input = [ + { key: 'a', value: true }, + { key: 'b', value: false }, + { key: 'c', value: 7 }, + { key: 'd', value: 'string' }, + { key: 'e', value: null }, + { key: 'f', value: undefined }, + ]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([ + { entity_id: 'eid', key: 'a', value: 'true' }, + { entity_id: 'eid', key: 'b', value: 'false' }, + { entity_id: 'eid', key: 'c', value: '7' }, + { entity_id: 'eid', key: 'd', value: 'string' }, + { entity_id: 'eid', key: 'e', value: null }, + { entity_id: 'eid', key: 'f', value: null }, + ]); + }); + + it('emits lowercase version of keys and values', () => { + const input = [{ key: 'fOo', value: 'BaR' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); + }); + + it('skips very large values', () => { + const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + }); + + describe('buildEntitySearch', () => { + it('adds special keys even if missing', () => { + const input: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + expect(buildEntitySearch('eid', input)).toEqual([ + { entity_id: 'eid', key: 'apiversion', value: 'a' }, + { entity_id: 'eid', key: 'kind', value: 'b' }, + { entity_id: 'eid', key: 'metadata.name', value: 'n' }, + { entity_id: 'eid', key: 'metadata.namespace', value: null }, + { entity_id: 'eid', key: 'metadata.uid', value: null }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + ]); + }); + + it('adds relations', () => { + const input: Entity = { + relations: [ + { type: 't1', target: { kind: 'k', namespace: 'ns', name: 'a' } }, + { type: 't2', target: { kind: 'k', namespace: 'ns', name: 'b' } }, + ], + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + expect(buildEntitySearch('eid', input)).toEqual([ + { entity_id: 'eid', key: 'apiversion', value: 'a' }, + { entity_id: 'eid', key: 'kind', value: 'b' }, + { entity_id: 'eid', key: 'metadata.name', value: 'n' }, + { entity_id: 'eid', key: 'metadata.namespace', value: null }, + { entity_id: 'eid', key: 'metadata.uid', value: null }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + { entity_id: 'eid', key: 'relations.t1', value: 'k:ns/a' }, + { entity_id: 'eid', key: 'relations.t2', value: 'k:ns/b' }, + ]); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts new file mode 100644 index 0000000000..383b29fd70 --- /dev/null +++ b/plugins/catalog-backend/src/next/search.ts @@ -0,0 +1,192 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + stringifyEntityRef, +} from '@backstage/catalog-model'; + +export type DbSearchRow = { + entity_id: string; + key: string; + value: string | null; +}; + +// These are excluded in the generic loop, either because they do not make sense +// to index, or because they are special-case always inserted whether they are +// null or not +const SPECIAL_KEYS = [ + 'attachments', + 'relations', + 'status', + 'metadata.name', + 'metadata.namespace', + 'metadata.uid', + 'metadata.etag', + 'metadata.generation', +]; + +// The maximum length allowed for search values. These columns are indexed, and +// database engines do not like to index on massive values. For example, +// postgres will balk after 8191 byte line sizes. +const MAX_VALUE_LENGTH = 200; + +type Kv = { + key: string; + value: unknown; +}; + +// Helper for traversing through a nested structure and outputting a list of +// path->value entries of the leaves. +// +// For example, this yaml structure +// +// a: 1 +// b: +// c: null +// e: [f, g] +// h: +// - i: 1 +// j: k +// - i: 2 +// j: l +// +// will result in +// +// "a", 1 +// "b.c", null +// "b.e": "f" +// "b.e.f": true +// "b.e": "g" +// "b.e.g": true +// "h.i": 1 +// "h.j": "k" +// "h.i": 2 +// "h.j": "l" +export function traverse(root: unknown): Kv[] { + const output: Kv[] = []; + + function visit(path: string, current: unknown) { + if (SPECIAL_KEYS.includes(path)) { + return; + } + + // empty or scalar + if ( + current === undefined || + current === null || + ['string', 'number', 'boolean'].includes(typeof current) + ) { + output.push({ key: path, value: current }); + return; + } + + // unknown + if (typeof current !== 'object') { + return; + } + + // array + if (Array.isArray(current)) { + for (const item of current) { + // NOTE(freben): The reason that these are output in two different ways, + // is to support use cases where you want to express that MORE than one + // tag is present in a list. Since the EntityFilters structure is a + // record, you can't have several entries of the same key. Therefore + // you will have to match on + // + // { "a.b": ["true"], "a.c": ["true"] } + // + // rather than + // + // { "a": ["b", "c"] } + // + // because the latter means EITHER b or c has to be present. + visit(path, item); + if (typeof item === 'string') { + output.push({ key: `${path}.${item}`, value: true }); + } + } + return; + } + + // object + for (const [key, value] of Object.entries(current!)) { + visit(path ? `${path}.${key}` : key, value); + } + } + + visit('', root); + + return output; +} + +// Translates a number of raw data rows to search table rows +export function mapToRows(input: Kv[], entityId: string): DbSearchRow[] { + const result: DbSearchRow[] = []; + + for (const { key: rawKey, value: rawValue } of input) { + const key = rawKey.toLocaleLowerCase('en-US'); + if (rawValue === undefined || rawValue === null) { + result.push({ entity_id: entityId, key, value: null }); + } else { + const value = String(rawValue).toLocaleLowerCase('en-US'); + if (value.length <= MAX_VALUE_LENGTH) { + result.push({ entity_id: entityId, key, value }); + } + } + } + + return result; +} + +/** + * Generates all of the search rows that are relevant for this entity. + * + * @param entityId The uid of the entity + * @param entity The entity + * @returns A list of entity search rows + */ +export function buildEntitySearch( + entityId: string, + entity: Entity, +): DbSearchRow[] { + // Visit the base structure recursively + const raw = traverse(entity); + + // Start with some special keys that are always present because you want to + // be able to easily search for null specifically + raw.push({ key: 'metadata.name', value: entity.metadata.name }); + raw.push({ key: 'metadata.namespace', value: entity.metadata.namespace }); + raw.push({ key: 'metadata.uid', value: entity.metadata.uid }); + + // Namespace not specified has the default value "default", so we want to + // match on that as well + if (!entity.metadata.namespace) { + raw.push({ key: 'metadata.namespace', value: ENTITY_DEFAULT_NAMESPACE }); + } + + // Visit relations + for (const relation of entity.relations ?? []) { + raw.push({ + key: `relations.${relation.type}`, + value: stringifyEntityRef(relation.target), + }); + } + + return mapToRows(raw, entityId); +} diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index 0aeb290224..202576fddd 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -13,27 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity, - EntityName, LocationSpec, Location, EntityRelationSpec, } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; -import { Observable } from '@backstage/core'; // << nooo - -export interface LocationEntity { - apiVersion: 'backstage.io/v1alpha1'; - kind: 'Location'; - metadata: { - name: string; // type:target - namespace: 'default'; - }; - spec: { - location: { type: string; target: string }; - }; -} export interface LocationService { createLocation( @@ -45,20 +32,11 @@ export interface LocationService { deleteLocation(id: string): Promise; } -export type EntityMessage = - | { all: Entity[] } - | { added: Entity[]; removed: EntityName[] }; - export interface LocationStore { - // extends EntityProvider createLocation(spec: LocationSpec): Promise; listLocations(): Promise; getLocation(id: string): Promise; deleteLocation(id: string): Promise; - - location$(): Observable< - { all: Location[] } | { added: Location[]; removed: Location[] } - >; } export interface CatalogProcessingEngine { @@ -66,8 +44,17 @@ export interface CatalogProcessingEngine { stop(): Promise; } +export type EntityProviderMutation = + | { type: 'full'; entities: Entity[] } + | { type: 'delta'; added: Entity[]; removed: Entity[] }; + +export interface EntityProviderConnection { + applyMutation(mutation: EntityProviderMutation): Promise; +} + export interface EntityProvider { - entityChange$(): Observable; + getProviderName(): string; + connect(connection: EntityProviderConnection): Promise; } export type EntityProcessingRequest = { @@ -81,7 +68,7 @@ export type EntityProcessingResult = ok: true; state: Map; completedEntity: Entity; - deferredEntites: Entity[]; + deferredEntities: Entity[]; relations: EntityRelationSpec[]; errors: Error[]; } @@ -103,20 +90,27 @@ export type ProcessingItemResult = { deferredEntities: Entity[]; }; -export type AddProcessingItemRequest = { - type: 'entity' | 'provider'; - id: string; - entities: Entity[]; -}; - -export type ProccessingItem = { +export type ProcessingItem = { id: string; entity: Entity; state: Map; }; +export type ReplaceProcessingItemsRequest = + | { + sourceKey: string; + items: Entity[]; + type: 'full'; + } + | { + sourceKey: string; + added: Entity[]; + removed: Entity[]; + type: 'delta'; + }; + export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; - getNextProcessingItem(): Promise; - addProcessingItems(request: AddProcessingItemRequest): Promise; + getNextProcessingItem(): Promise; + replaceProcessingItems(request: ReplaceProcessingItemsRequest): Promise; } diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts new file mode 100644 index 0000000000..0764912f0f --- /dev/null +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { DefaultCatalogCollator } from './DefaultCatalogCollator'; + +const expectedEntities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-entity', + description: 'The expected description', + }, + spec: { + type: 'some-type', + lifecycle: 'experimental', + owner: 'someone', + }, + }, +]; + +jest.mock('cross-fetch', () => ({ + __esModule: true, + default: async () => { + return { + json: async () => { + return expectedEntities; + }, + }; + }, +})); + +describe('DefaultCatalogCollator', () => { + let mockDiscoveryApi: jest.Mocked; + let collator: DefaultCatalogCollator; + + beforeEach(() => { + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getExternalBaseUrl: jest.fn(), + }; + collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi }); + }); + + it('fetches from the configured catalog service', async () => { + const documents = await collator.execute(); + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog'); + expect(documents).toHaveLength(expectedEntities.length); + }); + + it('maps a returned entity to an expected CatalogEntityDocument', async () => { + const documents = await collator.execute(); + expect(documents[0]).toMatchObject({ + title: expectedEntities[0].metadata.name, + location: '/catalog/default/component/test-entity', + text: expectedEntities[0].metadata.description, + namespace: 'default', + componentType: expectedEntities[0]!.spec!.type, + lifecycle: expectedEntities[0]!.spec!.lifecycle, + owner: expectedEntities[0]!.spec!.owner, + }); + }); + + it('maps a returned entity with a custom locationTemplate', async () => { + // Provide an alternate location template. + collator = new DefaultCatalogCollator({ + discovery: mockDiscoveryApi, + locationTemplate: '/software/:name', + }); + + const documents = await collator.execute(); + expect(documents[0]).toMatchObject({ + location: '/software/test-entity', + }); + }); +}); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index 72bf43f8f9..068b9e36ae 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -23,13 +23,35 @@ export interface CatalogEntityDocument extends IndexableDocument { componentType: string; namespace: string; kind: string; + lifecycle: string; + owner: string; } export class DefaultCatalogCollator implements DocumentCollator { protected discovery: PluginEndpointDiscovery; + protected locationTemplate: string; - constructor(discovery: PluginEndpointDiscovery) { + constructor({ + discovery, + locationTemplate, + }: { + discovery: PluginEndpointDiscovery; + locationTemplate?: string; + }) { this.discovery = discovery; + this.locationTemplate = + locationTemplate || '/catalog/:namespace/:kind/:name'; + } + + protected applyArgsToFormat( + format: string, + args: Record, + ): string { + let formatted = format; + for (const [key, value] of Object.entries(args)) { + formatted = formatted.replace(`:${key}`, value); + } + return formatted.toLowerCase(); } async execute() { @@ -37,17 +59,20 @@ export class DefaultCatalogCollator implements DocumentCollator { const res = await fetch(`${baseUrl}/entities`); const entities: Entity[] = await res.json(); return entities.map( - (entity): CatalogEntityDocument => { + (entity: Entity): CatalogEntityDocument => { return { title: entity.metadata.name, - // TODO: Use a config-based template approach for entity location. - location: `/catalog/${ - entity.metadata.namespace || 'default' - }/component/${entity.metadata.name}`, + location: this.applyArgsToFormat(this.locationTemplate, { + namespace: entity.metadata.namespace || 'default', + kind: entity.kind, + name: entity.metadata.name, + }), text: entity.metadata.description || '', componentType: entity.spec?.type?.toString() || 'other', namespace: entity.metadata.namespace || 'default', kind: entity.kind, + lifecycle: (entity.spec?.lifecycle as string) || '', + owner: (entity.spec?.owner as string) || '', }; }, ); diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 5fd93562d6..4b0d264a52 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/plugin-catalog-import +## 0.5.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + +## 0.5.3 + +### Patch Changes + +- 29a7e4be8: allow import from HTTP repositories +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.5.2 ### Patch Changes diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md index 97371ce0e7..5e773ad288 100644 --- a/plugins/catalog-import/README.md +++ b/plugins/catalog-import/README.md @@ -23,15 +23,7 @@ Some features are not yet available for all supported Git providers. yarn add @backstage/plugin-catalog-import ``` -2. Add the plugin to the app: - -```ts -// packages/app/src/plugins.ts - -export { catalogImportPlugin } from '@backstage/plugin-catalog-import'; -``` - -3. Register the `CatalogImportPage` at the `/catalog-import` path: +2. Add the `CatalogImportPage` extension to the app: ```tsx // packages/app/src/App.tsx diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 6af4b02bd0..ca540fe8be 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.5.2", + "version": "0.5.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "dependencies": { "@backstage/catalog-model": "^0.7.6", "@backstage/catalog-client": "^0.3.9", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/integration": "^0.5.0", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -53,7 +53,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx index bfb2434926..3e918db419 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -127,7 +127,9 @@ describe('', () => { expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0); expect(onAnalysisFn).toBeCalledTimes(0); expect(errorApi.post).toBeCalledTimes(0); - expect(getByText('Must start with https://.')).toBeInTheDocument(); + expect( + getByText('Must start with http:// or https://.'), + ).toBeInTheDocument(); }); it('should analyze single location', async () => { diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx index a33fe388d8..49bd776db5 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -132,8 +132,8 @@ export const StepInitAnalyzeUrl = ({ validate: { httpsValidator: (value: any) => (typeof value === 'string' && - value.match(/^https:\/\//) !== null) || - 'Must start with https://.', + value.match(/^http[s]?:\/\//) !== null) || + 'Must start with http:// or https://.', }, })} required diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index eccfa953a4..d6254a347a 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-catalog +## 0.5.6 + +### Patch Changes + +- 19a4dd710: Removed unused `swr` dependency. +- da546ce00: Support `gridItem` variant for `EntityLinksCard`. +- e0c9ed759: Add `if` prop to `EntityLayout.Route` to conditionally render tabs +- 1a142ae8a: Switch out the time-based personal greeting for a plain title on the catalog index page, and remove the clocks for different timezones. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + +## 0.5.5 + +### Patch Changes + +- 96728a2af: SystemDiagramCard UI improvements +- 87c4f59de: Add low german greeting +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.5.4 ### Patch Changes diff --git a/plugins/catalog/README.md b/plugins/catalog/README.md index e9405a41f5..611d2989e8 100644 --- a/plugins/catalog/README.md +++ b/plugins/catalog/README.md @@ -1,12 +1,25 @@ -# Catalog Frontend - -WORK IN PROGRESS +# Backstage Catalog Frontend This is the frontend part of the default catalog plugin. It will implement the core API for handling your catalog of software, and supply the base views to show and manage them. +## Getting Started + +This frontend plugin can be started in a standalone mode from directly in this package +with `yarn start`. However, it will have limited functionality and that process is +most convenient when developing the catalog frontend plugin itself. + +To evaluate the catalog and have a greater amount of functionality available, from the main +Backstage root folder, instead do: + +```bash +yarn dev +``` + +This will launch both frontend and backend in the same window, populated with some example entities. + ## Links - [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 8561ae3188..4f54735611 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.5.4", + "version": "0.5.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ "dependencies": { "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -49,11 +49,10 @@ "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", - "swr": "^0.3.0" + "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@microsoft/microsoft-graph-types": "^1.25.0", diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 298aeacfec..a57b223afe 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -14,37 +14,24 @@ * limitations under the License. */ -import { - configApiRef, - Header, - HomepageTimer, - identityApiRef, - Page, - useApi, -} from '@backstage/core'; +import { configApiRef, Header, Page, useApi } from '@backstage/core'; import React from 'react'; -import { getTimeBasedGreeting } from './utils/timeUtil'; type Props = { children?: React.ReactNode; }; const CatalogLayout = ({ children }: Props) => { - const greeting = getTimeBasedGreeting(); - const profile = useApi(identityApiRef).getProfile(); - const userId = useApi(identityApiRef).getUserId(); - const orgName = useApi(configApiRef).getOptionalString('organization.name'); + const orgName = + useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; return (
- -
+ /> {children}
); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 7356ccc27b..c5b51b8334 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -101,4 +101,39 @@ describe('EntityLayout', () => { expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); }); + + it('should conditionally render tabs', async () => { + const shouldRenderTab = (e: Entity) => e.metadata.name === 'my-entity'; + const shouldNotRenderTab = (e: Entity) => e.metadata.name === 'some-entity'; + + const rendered = await renderInTestApp( + + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+ +
tabbed-test-content-3
+
+
+
+
, + ); + + expect(rendered.queryByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-title-2')).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-title-3')).toBeInTheDocument(); + }); }); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 6a9fa38e73..dfd27631f8 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -20,13 +20,14 @@ import { RELATION_OWNED_BY, } from '@backstage/catalog-model'; import { + attachComponentData, Content, Header, HeaderLabel, IconComponent, Page, Progress, - TabbedLayout, + RoutedTabs, } from '@backstage/core'; import { EntityContext, @@ -34,14 +35,69 @@ import { getEntityRelations, useEntityCompoundName, } from '@backstage/plugin-catalog-react'; -import { Box } from '@material-ui/core'; +import { Box, TabProps } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { default as React, useContext, useState } from 'react'; +import { + Children, + default as React, + Fragment, + isValidElement, + useContext, + useState, +} from 'react'; import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +type SubRoute = { + path: string; + title: string; + children: JSX.Element; + if?: (entity: Entity) => boolean; + tabProps?: TabProps; +}; + +const Route: (props: SubRoute) => null = () => null; + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +function createSubRoutesFromChildren( + childrenProps: React.ReactNode, + entity: Entity | undefined, +): SubRoute[] { + // Directly comparing child.type with Route will not work with in + // combination with react-hot-loader in storybook + // https://github.com/gaearon/react-hot-loader/issues/304 + const routeType = ( + +
+ + ).type; + + return Children.toArray(childrenProps).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children, entity); + } + + if (child.type !== routeType) { + throw new Error('Child of EntityLayout must be an EntityLayout.Route'); + } + + const { path, title, children, if: condition, tabProps } = child.props; + if (condition && entity && !condition(entity)) { + return []; + } + + return [{ path, title, children, tabProps }]; + }); +} + const EntityLayoutTitle = ({ entity, title, @@ -139,6 +195,7 @@ export const EntityLayout = ({ const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); + const routes = createSubRoutesFromChildren(children, entity); const { headerTitle, headerType } = headerProps( kind, namespace, @@ -175,7 +232,7 @@ export const EntityLayout = ({ {loading && } - {entity && {children}} + {entity && } {error && ( @@ -192,4 +249,4 @@ export const EntityLayout = ({ ); }; -EntityLayout.Route = TabbedLayout.Route; +EntityLayout.Route = Route; diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx index f7e558e285..59de0a4587 100644 --- a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx @@ -27,9 +27,10 @@ type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; cols?: ColumnBreakpoints | number; + variant?: 'gridItem'; }; -export const EntityLinksCard = ({ cols = undefined }: Props) => { +export const EntityLinksCard = ({ cols = undefined, variant }: Props) => { const { entity } = useEntity(); const app = useApp(); @@ -39,7 +40,7 @@ export const EntityLinksCard = ({ cols = undefined }: Props) => { const links = entity?.metadata?.links; return ( - + {!links || links.length === 0 ? ( ) : ( diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index ff01f44258..8c6d6e094d 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-circleci +## 0.2.13 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.12 ### Patch Changes diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index d30e950aa9..868ae6e26a 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -13,26 +13,22 @@ Website: [https://circleci.com/](https://circleci.com/) yarn add @backstage/plugin-circleci ``` -2. Add plugin itself: +2. Add the `EntityCircleCIContent` extension to the entity page in the app: -```js -// packages/app/src/plugins.ts -export { plugin as Circleci } from '@backstage/plugin-circleci'; -``` - -3. Register the plugin router: - -```jsx +```tsx // packages/app/src/components/catalog/EntityPage.tsx +import { EntityCircleCIContent } from '@backstage/plugin-circleci'; -import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; - -// Then somewhere inside -} -/>; +// ... +const serviceEntityPage = ( + + ... + + + + ... + +); ``` 4. Add proxy config: diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 5eb91d08bb..20c757eda0 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.2.12", + "version": "0.2.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 75e877ccb6..cb2a08fce1 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,8 +32,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/code-coverage-backend/.eslintrc.js b/plugins/code-coverage-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/code-coverage-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md new file mode 100644 index 0000000000..d7a0f136f5 --- /dev/null +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -0,0 +1,16 @@ +# @backstage/plugin-code-coverage-backend + +## 0.1.3 + +### Patch Changes + +- d47c2628b: Include migrations + +## 0.1.2 + +### Patch Changes + +- 55b2fc0c0: Update tests to function in windows +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 diff --git a/plugins/code-coverage-backend/README.md b/plugins/code-coverage-backend/README.md new file mode 100644 index 0000000000..101bee3e3f --- /dev/null +++ b/plugins/code-coverage-backend/README.md @@ -0,0 +1,154 @@ +# code-coverage-backend + +This is the backend part of the `code-coverage` plugin. It takes care of processing various coverage formats and standardizing them into a single json format, used by the frontend. + +## Configuring your entity + +In order to use this plugin, you must set the `backstage.io/code-coverage` annotation. + +```yaml +metadata: + annotations: + backstage.io/code-coverage: enabled +``` + +There's a feature to only include files that are in VCS in the coverage report, this is helpful to not count generated files for example. To enable this set the `backstage.io/code-coverage` annotation to `scm-only`. + +```yaml +metadata: + annotations: + backstage.io/code-coverage: scm-only +``` + +Note: It may be required to set the [`backstage.io/source-location` annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location), however this should generally not be needed. + +## API + +### Adding a Cobertura report + +POST a Cobertura XML file to `/report` + +Example: + +```json +// curl -X POST -H "Content-Type:text/xml" -d @cobertura.xml "localhost:7000/api/code-coverage/report?entity=component:default/entity-name&coverageType=cobertura" +{ + "links": [ + { + "href": "http://localhost:7000/api/code-coverage/report?entity=component:default/entity-name", + "rel": "coverage" + } + ] +} +``` + +### Adding a JaCoCo report + +POST a JaCoCo XML file to `/report` + +Example: + +```json +// curl -X POST -H "Content-Type:text/xml" -d @jacoco.xml "localhost:7000/api/code-coverage/report?entity=component:default/entity-name&coverageType=jacoco" +{ + "links": [ + { + "href": "http://localhost:7000/api/code-coverage/report?entity=component:default/entity-name", + "rel": "coverage" + } + ] +} +``` + +### Reading json coverage + +GET `/report` + +Example: + +```json +// curl localhost:7000/api/code-coverage/report?entity=component:default/entity-name +{ + "aggregate": { + "branch": { + "available": 0, + "covered": 0, + "missed": 0, + "percentage": 0 + }, + "line": { + "available": 5, + "covered": 4, + "missed": 1, + "percentage": 80 + } + }, + "entity": { + "kind": "Component", + "name": "entity-name", + "namespace": "default" + }, + "files": [ + { + "branchHits": {}, + "filename": "main.go", + "lineHits": { + "117": 12, + "142": 8, + "34": 8, + "42": 0, + "58": 6 + } + } + ] +} +``` + +### Coverage history + +GET `/history` + +Example + +```json +// curl localhost:7000/api/code-coverage/history?entity=component:default/entity-name +{ + "entity": { + "kind": "Component", + "name": "entity-name", + "namespace": "default" + }, + "history": [ + { + "branch": { + "available": 0, + "covered": 0, + "missed": 0, + "percentage": 0 + }, + "line": { + "available": 299, + "covered": 116, + "missed": 183, + "percentage": 38.8 + }, + "timestamp": 1615490766141 + }, + { + "branch": { + "available": 0, + "covered": 0, + "missed": 0, + "percentage": 0 + }, + "line": { + "available": 299, + "covered": 116, + "missed": 183, + "percentage": 38.8 + }, + "timestamp": 1615406307929 + } + ] +} +``` diff --git a/plugins/code-coverage-backend/migrations/20210302_init.js b/plugins/code-coverage-backend/migrations/20210302_init.js new file mode 100644 index 0000000000..538f658948 --- /dev/null +++ b/plugins/code-coverage-backend/migrations/20210302_init.js @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Note for the reader: the knex increments types automatically make it a + // primary column, whether you like it or not. That's why the id column is + // not marked as primary as one might have expected; it's only used for + // lookups by ID. Because, SQLite and MySQL don't return RETURNING on + // inserts ... so we want a manually generated key for lookups (an uuid), + // and also an index for ordering guarantees :) + await knex.schema.createTable('code_coverage', table => { + table.comment('The table of code coverage'); + table + .bigIncrements('index') + .notNullable() + .comment('An insert counter to ensure ordering'); + table.uuid('id').notNullable().comment('The ID of the code coverage'); + table + .text('entity') + .notNullable() + .comment('The entity ref that this code coverage applies to'); + table + .text('coverage') + .notNullable() + .comment('The coverage json as a string'); + table + .dateTime('created_at') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this entry was created'); + table.index('index', 'code_coverage_index_idx'); + table.index('id', 'code_coverage_id_idx'); + table.index('entity', 'code_coverage_entity_idx'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('code_coverage', table => { + table.dropIndex([], 'code_coverage_index_idx'); + table.dropIndex([], 'code_coverage_id_idx'); + table.dropIndex([], 'code_coverage_entity_idx'); + }); + await knex.schema.dropTable('code_coverage'); +}; diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json new file mode 100644 index 0000000000..c2bebbf293 --- /dev/null +++ b/plugins/code-coverage-backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-code-coverage-backend", + "version": "0.1.3", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.6.3", + "@backstage/catalog-client": "^0.3.10", + "@backstage/catalog-model": "^0.7.7", + "@backstage/config": "^0.1.4", + "@backstage/errors": "^0.1.1", + "@backstage/integration": "^0.5.1", + "@types/express": "^4.17.6", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "express-xml-bodyparser": "^0.3.0", + "knex": "^0.95.1", + "uuid": "^8.3.2", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.9", + "@types/express-xml-bodyparser": "^0.3.2", + "@types/supertest": "^2.0.8", + "msw": "^0.21.2", + "supertest": "^4.0.2", + "xml2js": "^0.4.23" + }, + "files": [ + "dist", + "migrations/**/*.{js,d.ts}" + ] +} diff --git a/plugins/code-coverage-backend/src/index.ts b/plugins/code-coverage-backend/src/index.ts new file mode 100644 index 0000000000..7612c392a2 --- /dev/null +++ b/plugins/code-coverage-backend/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './service/router'; diff --git a/plugins/code-coverage-backend/src/run.ts b/plugins/code-coverage-backend/src/run.ts new file mode 100644 index 0000000000..b96989e4b8 --- /dev/null +++ b/plugins/code-coverage-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/code-coverage-backend/src/service/CodeCoverageDatabase.test.ts b/plugins/code-coverage-backend/src/service/CodeCoverageDatabase.test.ts new file mode 100644 index 0000000000..688eeb4ba2 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/CodeCoverageDatabase.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SingleConnectionDatabaseManager } from '@backstage/backend-common'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { + CodeCoverageDatabase, + CodeCoverageStore, +} from './CodeCoverageDatabase'; +import { JsonCodeCoverage } from './types'; + +const db = SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: ':memory:', + }, + }, + }), +).forPlugin('code-coverage'); + +const coverage: Array = [ + { + metadata: { + generationTime: 1234567890, + vcs: { + location: 'local', + type: 'local', + }, + }, + entity: { + kind: 'Component', + name: 'test-entity', + namespace: 'default', + }, + files: [ + { + filename: 'src/main.py', + lineHits: { + '10': 5, + '11': 4, + '12': 4, + }, + branchHits: {}, + }, + ], + }, + { + metadata: { + generationTime: 2345678901, + vcs: { + location: 'local', + type: 'local', + }, + }, + entity: { + kind: 'Component', + name: 'test-entity', + namespace: 'default', + }, + files: [ + { + filename: 'src/main.py', + lineHits: { + '10': 5, + '11': 4, + '12': 4, + '22': 0, + '23': 0, + '24': 0, + '30': 1, + }, + branchHits: { + '10': { + available: 2, + covered: 1, + missed: 1, + }, + }, + }, + ], + }, +]; + +let database: CodeCoverageStore; +describe('CodeCoverageDatabase', () => { + beforeAll(async () => { + const client = await db.getClient(); + database = await CodeCoverageDatabase.create(client); + await database.insertCodeCoverage(coverage[0]); + await database.insertCodeCoverage(coverage[1]); + }); + + describe('insertCodeCoverage', () => { + it('can insert code coverage', async () => { + const ncov = { + metadata: { + generationTime: 3456789012, + vcs: { + location: 'local', + type: 'local', + }, + }, + entity: { + kind: 'Component', + name: 'test-entity-for-insert', + namespace: 'default', + }, + files: [ + { + filename: 'src/main.py', + lineHits: { + '10': 5, + '11': 4, + '12': 4, + }, + branchHits: {}, + }, + ], + }; + const { codeCoverageId } = await database.insertCodeCoverage(ncov); + expect(codeCoverageId.length).not.toBe(0); + }); + }); + + describe('getCodeCoverage', () => { + it("can get coverage that's in the database", async () => { + const cov = await database.getCodeCoverage( + stringifyEntityRef({ + name: 'test-entity', + kind: 'Component', + namespace: 'default', + }), + ); + expect(cov).toEqual(coverage[1]); + }); + }); + + describe('getHistory', () => { + it("can get history that's in the database", async () => { + const cov = await database.getHistory( + stringifyEntityRef({ + name: 'test-entity', + kind: 'Component', + namespace: 'default', + }), + 5, + ); + expect(cov.history.length).toEqual(2); + expect(cov.history).toEqual([ + { + branch: { + available: 2, + covered: 1, + missed: 1, + percentage: 50, + }, + line: { + available: 7, + covered: 4, + missed: 3, + percentage: 57.14, + }, + timestamp: 2345678901, + }, + { + branch: { + available: 0, + covered: 0, + missed: 0, + percentage: 0, + }, + line: { + available: 3, + covered: 3, + missed: 0, + percentage: 100, + }, + timestamp: 1234567890, + }, + ]); + }); + }); +}); diff --git a/plugins/code-coverage-backend/src/service/CodeCoverageDatabase.ts b/plugins/code-coverage-backend/src/service/CodeCoverageDatabase.ts new file mode 100644 index 0000000000..390fd815f1 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/CodeCoverageDatabase.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { resolvePackagePath } from '@backstage/backend-common'; +import { NotFoundError } from '@backstage/errors'; +import { parseEntityName, stringifyEntityRef } from '@backstage/catalog-model'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import { aggregateCoverage } from './CoverageUtils'; +import { JsonCodeCoverage, JsonCoverageHistory } from './types'; + +export type RawDbCoverageRow = { + id: string; + entity: string; + coverage: string; +}; + +export interface CodeCoverageStore { + insertCodeCoverage( + coverage: JsonCodeCoverage, + ): Promise<{ codeCoverageId: string }>; + getCodeCoverage(entity: string): Promise; + getHistory(entity: string, limit: number): Promise; +} + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-code-coverage-backend', + 'migrations', +); + +export class CodeCoverageDatabase implements CodeCoverageStore { + static async create(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + }); + return new CodeCoverageDatabase(knex); + } + + constructor(private readonly db: Knex) {} + + async insertCodeCoverage( + coverage: JsonCodeCoverage, + ): Promise<{ codeCoverageId: string }> { + const codeCoverageId = uuid(); + const entity = stringifyEntityRef({ + kind: coverage.entity.kind, + namespace: coverage.entity.namespace, + name: coverage.entity.name, + }); + + await this.db('code_coverage').insert({ + id: codeCoverageId, + entity: entity, + coverage: JSON.stringify(coverage), + }); + + return { codeCoverageId }; + } + + async getCodeCoverage(entity: string): Promise { + const [result] = await this.db('code_coverage') + .where({ entity: entity }) + .orderBy('index', 'desc') + .limit(1) + .select(); + + if (!result) { + throw new NotFoundError( + `No coverage for entity '${JSON.stringify(entity)}' found`, + ); + } + + try { + return JSON.parse(result.coverage); + } catch (error) { + throw new Error(`Failed to parse coverage for '${entity}', ${error}`); + } + } + + async getHistory( + entity: string, + limit: number, + ): Promise { + const res = await this.db('code_coverage') + .where({ entity: entity }) + .orderBy('index', 'desc') + .limit(limit) + .select(); + + const history = res + .map(r => JSON.parse(r.coverage)) + .map(c => aggregateCoverage(c)); + + const entityName = parseEntityName(entity); + + return { + entity: { + name: entityName.name, + kind: entityName.kind, + namespace: entityName.namespace, + }, + history: history, + }; + } +} diff --git a/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts b/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts new file mode 100644 index 0000000000..4a96e67338 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/CoverageUtils.test.ts @@ -0,0 +1,326 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import fs from 'fs'; +import { Readable } from 'stream'; +import { + calculatePercentage, + aggregateCoverage, + CoverageUtils, +} from './CoverageUtils'; +import { Entity } from '@backstage/catalog-model'; +import { parseString } from 'xml2js'; + +describe('calculatePercentage', () => { + [ + [100, 25, 25], + [100, 100, 100], + [133, 13, 9.77], + [0, 0, 0], + ].forEach(([a, c, e]) => { + it(`${c}/${a} === ${e}`, () => { + expect(calculatePercentage(a, c)).toEqual(e); + }); + }); +}); + +/* eslint-disable no-restricted-syntax */ + +describe('aggregateCoverage', () => { + [ + { + file: 'jacoco-jsoncoverage-files-1.json', + expected: { + branch: { + available: 68, + covered: 48, + missed: 20, + percentage: 70.59, + }, + line: { + available: 162, + covered: 105, + missed: 57, + percentage: 64.81, + }, + timestamp: 1234567890, + }, + }, + { + file: 'cobertura-jsoncoverage-files-1.json', + expected: { + branch: { + available: 0, + covered: 0, + missed: 0, + percentage: 0, + }, + line: { + available: 146, + covered: 145, + missed: 1, + percentage: 99.32, + }, + timestamp: 1234567890, + }, + }, + { + file: 'cobertura-jsoncoverage-files-2.json', + expected: { + branch: { + available: 466, + covered: 380, + missed: 86, + percentage: 81.55, + }, + line: { + available: 2632, + covered: 2079, + missed: 553, + percentage: 78.99, + }, + timestamp: 1234567890, + }, + }, + { + file: 'cobertura-jsoncoverage-files-3.json', + expected: { + branch: { + available: 73, + covered: 49, + missed: 24, + percentage: 67.12, + }, + line: { + available: 110, + covered: 91, + missed: 19, + percentage: 82.73, + }, + timestamp: 1234567890, + }, + }, + { + file: 'cobertura-jsoncoverage-files-4.json', + expected: { + branch: { + available: 0, + covered: 0, + missed: 0, + percentage: 0, + }, + line: { + available: 325, + covered: 0, + missed: 325, + percentage: 0, + }, + timestamp: 1234567890, + }, + }, + { + file: 'cobertura-jsoncoverage-files-5.json', + expected: { + branch: { + available: 38, + covered: 21, + missed: 17, + percentage: 55.26, + }, + line: { + available: 175, + covered: 124, + missed: 51, + percentage: 70.86, + }, + timestamp: 1234567890, + }, + }, + ].forEach(td => { + it(`processes ${td.file}`, () => { + const json = JSON.parse( + fs.readFileSync(`${__dirname}/__fixtures__/${td.file}`).toString(), + ); + const aggregate = aggregateCoverage({ + metadata: { + generationTime: 1234567890, + vcs: { + location: 'foo', + type: 'foo', + }, + }, + entity: { + kind: 'foo', + name: 'foo', + namespace: 'default', + }, + files: json, + }); + + expect(aggregate).toEqual(td.expected); + }); + }); +}); + +describe('CodeCoverageUtils', () => { + const scmFilesFixture = fs + .readFileSync(`${__dirname}/__fixtures__/cobertura-sourcefiles-1.txt`) + .toString() + .split('\n') + .map(f => { + return { path: f }; + }); + const scmIntegraions = { + byUrl: jest.fn().mockReturnValue({ + type: 'local', + title: 'local', + }), + }; + const scmTree = { + files: jest + .fn() + .mockReturnValue(new Promise((r, _e) => r(scmFilesFixture))), + }; + const urlReader = { + readTree: jest.fn().mockReturnValue(new Promise((r, _e) => r(scmTree))), + }; + const utils = new CoverageUtils(scmIntegraions, urlReader); + + describe('validateRequestBody', () => { + it('rejects missing content type', () => { + let err: Error | null = null; + try { + const mockRequest: Partial = { + // @ts-ignore + headers: {}, + }; + // @ts-ignore + utils.validateRequestBody(mockRequest as Request); + } catch (error) { + err = error; + } + expect(err?.message).toEqual('Content-Type missing'); + }); + + it('rejects unsupported content type', () => { + let err: Error | null = null; + try { + const mockRequest: Partial = { + headers: { + // @ts-ignore + 'content-type': 'application/json', + }, + }; + + // @ts-ignore + utils.validateRequestBody(mockRequest as Request); + } catch (error) { + err = error; + } + expect(err?.message).toEqual('Illegal Content-Type'); + }); + + it('parses the body', () => { + const mockRequest: Partial = { + headers: { + // @ts-ignore + 'content-type': 'text/xml', + }, + // @ts-ignore + body: Readable.from( + '', + ), + }; + + // @ts-ignore + const data: Readable = utils.validateRequestBody(mockRequest as Request); + + expect(data.read()).toContain(' { + const mockRequest: Partial = { + headers: { + // @ts-ignore + 'content-type': 'text/xml', + }, + // @ts-ignore + body: Readable.from( + '', + ), + }; + + it('ignores scm if annotation is not set', async () => { + const entity: Entity = { + kind: 'Component', + metadata: { + name: 'test-entity', + namespace: 'test', + }, + apiVersion: 'backstage.io/v1alpha1', + }; + + const { + scmFiles, + sourceLocation, + vcs, + body, // @ts-ignore + } = await utils.processCoveragePayload(entity, mockRequest); + let data; + // in normal flow the express app will already have done this through the middleware + parseString((body as Readable).read(), (_e, r) => { + data = r; + }); + + expect(scmFiles.length).toBe(0); + expect(vcs).toBeUndefined(); + expect(sourceLocation).toBeUndefined(); + expect(data).toEqual({ + report: { + $: { + name: 'example', + }, + }, + }); + }); + + it('populates scm data if annotation is set', async () => { + const entity: Entity = { + kind: 'Component', + metadata: { + name: 'test-entity', + namespace: 'test', + annotations: { + 'backstage.io/code-coverage': 'scm-only', + 'backstage.io/source-location': + 'url:https://github.com/example/test/', + }, + }, + apiVersion: 'backstage.io/v1alpha1', + }; + + const { + scmFiles, + sourceLocation, + vcs, // @ts-ignore + } = await utils.processCoveragePayload(entity, mockRequest); + + expect(scmFiles.length).toBe(scmFiles.length); + expect(vcs).not.toBeUndefined(); + expect(sourceLocation).not.toBeUndefined(); + }); + }); +}); diff --git a/plugins/code-coverage-backend/src/service/CoverageUtils.ts b/plugins/code-coverage-backend/src/service/CoverageUtils.ts new file mode 100644 index 0000000000..f41a1d05a0 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/CoverageUtils.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Request } from 'express'; +import { UrlReader } from '@backstage/backend-common'; +import { InputError, NotFoundError } from '@backstage/errors'; +import { + Entity, + getEntitySourceLocation, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { ScmIntegration, ScmIntegrations } from '@backstage/integration'; +import { AggregateCoverage, FileEntry, JsonCodeCoverage } from './types'; + +export const calculatePercentage = ( + available: number, + covered: number, +): number => { + if (available === 0) { + return 0; + } + return parseFloat(((covered / available) * 100).toFixed(2)); +}; + +export const aggregateCoverage = (c: JsonCodeCoverage): AggregateCoverage => { + let availableLine = 0; + let coveredLine = 0; + let availableBranch = 0; + let coveredBranch = 0; + c.files.forEach(f => { + availableLine += Object.keys(f.lineHits).length; + coveredLine += Object.values(f.lineHits).filter(l => l > 0).length; + + availableBranch += Object.keys(f.branchHits) + .map(b => parseInt(b, 10)) + .map((b: number) => f.branchHits[b].available) + .filter(Boolean) + .reduce((acc, curr) => acc + curr, 0); + coveredBranch += Object.keys(f.branchHits) + .map(b => parseInt(b, 10)) + .map((b: number) => f.branchHits[b].covered) + .filter(Boolean) + .reduce((acc, curr) => acc + curr, 0); + }); + + return { + timestamp: c.metadata.generationTime, + branch: { + available: availableBranch, + covered: coveredBranch, + missed: availableBranch - coveredBranch, + percentage: calculatePercentage(availableBranch, coveredBranch), + }, + line: { + available: availableLine, + covered: coveredLine, + missed: availableLine - coveredLine, + percentage: calculatePercentage(availableLine, coveredLine), + }, + }; +}; + +export class CoverageUtils { + constructor( + readonly scm: Partial, + readonly urlReader: Partial, + ) {} + + async processCoveragePayload( + entity: Entity, + req: Request, + ): Promise<{ + sourceLocation?: string; + vcs?: ScmIntegration; + scmFiles: string[]; + body: {}; + }> { + const enforceScmFiles = + entity.metadata.annotations?.['backstage.io/code-coverage'] === + 'scm-only' || false; + + let sourceLocation: string | undefined = undefined; + let vcs: ScmIntegration | undefined = undefined; + let scmFiles: string[] = []; + + if (enforceScmFiles) { + try { + const sl = getEntitySourceLocation(entity); + sourceLocation = sl.target; + } catch (e: unknown) { + // TODO: logging + } + + if (!sourceLocation) { + throw new InputError( + `No "backstage.io/source-location" annotation on entity ${stringifyEntityRef( + entity, + )}`, + ); + } + + vcs = this.scm.byUrl?.(sourceLocation); + if (!vcs) { + throw new InputError(`Unable to determine SCM from ${sourceLocation}`); + } + + const scmTree = await this.urlReader.readTree?.(sourceLocation); + if (!scmTree) { + throw new NotFoundError(`Unable to read tree from ${sourceLocation}`); + } + scmFiles = (await scmTree.files()).map(f => f.path); + } + + const body = this.validateRequestBody(req); + if (Object.keys(body).length === 0) { + throw new InputError('Unable to parse body'); + } + + return { + sourceLocation, + vcs, + scmFiles, + body, + }; + } + + async buildCoverage( + entity: Entity, + sourceLocation: string | undefined, + vcs: ScmIntegration | undefined, + files: FileEntry[], + ): Promise { + return { + metadata: { + vcs: { + type: vcs?.type || 'unknown', + location: sourceLocation || 'unknown', + }, + generationTime: Date.now(), + }, + entity: { + name: entity.metadata.name, + namespace: entity.metadata.namespace || 'default', + kind: entity.kind, + }, + files, + }; + } + + validateRequestBody(req: Request) { + const contentType = req.headers['content-type']; + if (!contentType) { + throw new InputError('Content-Type missing'); + } else if (!contentType.match(/^text\/xml($|;)/)) { + throw new InputError('Illegal Content-Type'); + } + const body = req.body; + if (!body) { + throw new InputError('Missing request body'); + } + return body; + } +} diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-1.json b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-1.json new file mode 100644 index 0000000000..b5b861254c --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-1.json @@ -0,0 +1,166 @@ +[ + { + "filename": "src/main/scala/com/example/Main.scala", + "lineHits": { + "100": 4, + "103": 2, + "104": 4, + "105": 4, + "112": 2, + "114": 4, + "115": 2, + "124": 3, + "125": 2, + "126": 3, + "127": 3, + "135": 3, + "136": 1, + "138": 2, + "139": 1, + "140": 1, + "143": 1, + "145": 1, + "146": 1, + "147": 1, + "148": 1, + "149": 1, + "150": 1, + "160": 2, + "163": 8, + "164": 4, + "166": 2, + "167": 4, + "168": 6, + "169": 6, + "170": 4, + "178": 1, + "179": 1, + "181": 1, + "182": 1, + "183": 3, + "187": 1, + "188": 3, + "202": 4, + "203": 6, + "204": 4, + "210": 5, + "26": 4, + "27": 4, + "28": 4, + "30": 4, + "33": 4, + "35": 2, + "36": 2, + "37": 4, + "38": 2, + "42": 2, + "44": 2, + "46": 2, + "48": 2, + "50": 2, + "52": 2, + "53": 2, + "55": 2, + "56": 2, + "58": 2, + "60": 2, + "61": 2, + "64": 2, + "65": 6, + "68": 4, + "73": 2, + "75": 10, + "77": 4, + "83": 1, + "84": 2, + "85": 3, + "86": 3, + "87": 1, + "88": 6, + "95": 2, + "97": 8, + "99": 2 + }, + "branchHits": {} + }, + { + "filename": "src/main/scala/com/example/Other.scala", + "lineHits": { + "103": 6, + "104": 2, + "105": 5, + "109": 1, + "110": 3, + "111": 3, + "113": 2, + "115": 2, + "116": 2, + "117": 2, + "119": 2, + "123": 3, + "124": 1, + "129": 7, + "132": 2, + "133": 4, + "134": 2, + "138": 1, + "139": 2, + "140": 2, + "142": 4, + "143": 3, + "144": 5, + "145": 3, + "146": 5, + "147": 3, + "149": 2, + "20": 1, + "21": 1, + "23": 1, + "25": 2, + "26": 1, + "27": 2, + "29": 2, + "31": 2, + "32": 2, + "33": 2, + "34": 2, + "37": 0, + "42": 4, + "44": 2, + "45": 2, + "46": 2, + "49": 4, + "50": 10, + "52": 4, + "59": 5, + "61": 4, + "64": 2, + "65": 1, + "66": 1, + "68": 1, + "70": 4, + "71": 2, + "72": 2, + "73": 1, + "74": 2, + "79": 1, + "85": 1, + "86": 3, + "87": 1, + "91": 1, + "92": 4, + "93": 3, + "94": 2 + }, + "branchHits": {} + }, + { + "filename": "src/main/scala/com/example/utils/Util.scala", + "lineHits": { + "13": 2, + "8": 4, + "7": 2 + }, + "branchHits": {} + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-2.json b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-2.json new file mode 100644 index 0000000000..ddfcc729fd --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-2.json @@ -0,0 +1,3952 @@ +[ + { + "filename": "src/index.ts", + "lineHits": { + "1": 3, + "3": 3, + "5": 3, + "6": 3, + "7": 3, + "8": 3, + "9": 3, + "11": 3, + "12": 3, + "14": 3 + }, + "branchHits": {} + }, + { + "filename": "src/utils.ts", + "lineHits": { + "4": 104, + "7": 1178, + "12": 20, + "16": 2, + "29": 3105, + "33": 1601, + "37": 15, + "56": 20, + "64": 42, + "65": 2, + "1": 18, + "8": 589, + "9": 589, + "15": 1, + "18": 2, + "20": 0, + "22": 2, + "28": 18, + "30": 776, + "34": 1601, + "42": 158, + "48": 1, + "53": 15, + "57": 0, + "58": 1, + "60": 19, + "61": 19, + "63": 19, + "66": 2, + "67": 2, + "68": 2, + "69": 1, + "70": 1, + "73": 19 + }, + "branchHits": { + "8": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "57": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "68": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "69": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "73": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/controller/VideoContextPlayerCoordinator.ts", + "lineHits": { + "50": 8, + "74": 66, + "137": 33, + "143": 5, + "157": 18, + "217": 5, + "240": 5, + "309": 48, + "343": 48, + "357": 48, + "405": 8, + "420": 20, + "442": 19, + "445": 1, + "460": 20, + "466": 20, + "480": 16, + "488": 8, + "520": 16, + "530": 14, + "619": 0, + "627": 2, + "636": 0, + "640": 8, + "651": 8, + "661": 12, + "673": 37, + "690": 8, + "711": 7, + "736": 4, + "740": 29, + "1": 4, + "2": 4, + "31": 4, + "33": 4, + "37": 4, + "39": 4, + "41": 4, + "76": 33, + "77": 33, + "78": 33, + "79": 33, + "80": 33, + "81": 33, + "82": 33, + "83": 33, + "84": 33, + "85": 33, + "86": 33, + "87": 33, + "90": 33, + "91": 33, + "92": 33, + "93": 33, + "94": 33, + "95": 33, + "96": 33, + "100": 33, + "103": 33, + "106": 33, + "107": 33, + "110": 33, + "111": 33, + "112": 33, + "113": 33, + "115": 33, + "116": 33, + "118": 33, + "120": 33, + "121": 33, + "122": 0, + "128": 33, + "131": 33, + "138": 0, + "139": 0, + "147": 1, + "148": 1, + "149": 1, + "150": 1, + "159": 2, + "162": 0, + "164": 0, + "167": 1, + "171": 1, + "174": 1, + "176": 1, + "180": 1, + "183": 7, + "185": 0, + "186": 7, + "188": 7, + "189": 7, + "194": 0, + "196": 0, + "200": 0, + "201": 1, + "203": 1, + "205": 1, + "207": 1, + "209": 1, + "211": 1, + "213": 1, + "225": 1, + "233": 0, + "234": 0, + "245": 1, + "247": 0, + "250": 0, + "253": 1, + "254": 1, + "255": 0, + "258": 0, + "263": 1, + "276": 0, + "278": 1, + "283": 1, + "285": 1, + "286": 0, + "288": 1, + "289": 0, + "291": 0, + "293": 0, + "301": 1, + "315": 24, + "316": 24, + "318": 24, + "319": 24, + "322": 10, + "323": 10, + "325": 0, + "328": 0, + "332": 0, + "333": 0, + "337": 0, + "338": 0, + "341": 24, + "346": 24, + "347": 24, + "351": 24, + "352": 24, + "354": 24, + "359": 24, + "361": 24, + "362": 24, + "363": 0, + "366": 24, + "377": 24, + "378": 24, + "379": 24, + "380": 24, + "381": 24, + "383": 24, + "384": 24, + "386": 24, + "396": 24, + "397": 24, + "406": 4, + "408": 0, + "413": 4, + "418": 20, + "429": 0, + "433": 0, + "434": 0, + "435": 0, + "440": 20, + "443": 19, + "450": 1, + "451": 1, + "452": 1, + "462": 0, + "464": 20, + "468": 20, + "470": 0, + "485": 8, + "490": 0, + "491": 0, + "493": 8, + "496": 0, + "497": 0, + "500": 8, + "502": 8, + "507": 8, + "525": 8, + "527": 8, + "528": 8, + "531": 14, + "534": 0, + "535": 0, + "538": 14, + "539": 14, + "543": 8, + "544": 8, + "549": 8, + "552": 0, + "553": 1, + "554": 1, + "555": 1, + "559": 1, + "561": 1, + "562": 1, + "567": 2, + "570": 1, + "572": 2, + "574": 2, + "575": 2, + "579": 1, + "580": 1, + "582": 1, + "583": 1, + "588": 1, + "589": 1, + "592": 1, + "594": 1, + "595": 1, + "599": 0, + "600": 0, + "605": 0, + "606": 0, + "610": 0, + "615": 0, + "616": 0, + "620": 0, + "621": 0, + "625": 8, + "628": 2, + "629": 0, + "630": 2, + "632": 1, + "647": 4, + "653": 4, + "654": 4, + "656": 4, + "662": 8, + "663": 8, + "665": 0, + "666": 0, + "667": 0, + "675": 8, + "676": 8, + "679": 8, + "680": 8, + "682": 33, + "683": 33, + "684": 33, + "685": 33, + "687": 33, + "694": 4, + "695": 4, + "697": 4, + "698": 4, + "699": 4, + "700": 4, + "720": 1, + "721": 1, + "722": 1, + "726": 0, + "727": 1, + "732": 1, + "734": 2, + "737": 2, + "745": 1, + "746": 1, + "749": 1, + "750": 0, + "752": 1, + "754": 24, + "756": 4 + }, + "branchHits": { + "122": { + "covered": 0, + "missed": 1, + "available": 1 + }, + "138": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "162": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "164": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "185": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "200": { + "covered": 7, + "missed": 0, + "available": 7 + }, + "233": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "234": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "255": { + "covered": 3, + "missed": 0, + "available": 3 + }, + "283": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "286": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "289": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "291": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "293": { + "covered": 2, + "missed": 2, + "available": 4 + }, + "323": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "332": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "351": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "359": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "361": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "363": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "386": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "408": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "470": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "531": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "552": { + "covered": 5, + "missed": 3, + "available": 8 + }, + "579": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "629": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "726": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "749": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "750": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/controller/VideoContextPlayerCoordinatorFactory.ts", + "lineHits": { + "9": 8, + "11": 33, + "14": 66, + "2": 4, + "3": 4, + "4": 4, + "5": 4, + "6": 4, + "12": 33, + "15": 33, + "16": 33, + "17": 33, + "18": 33, + "19": 33, + "20": 33, + "22": 33, + "24": 33, + "25": 33, + "28": 0, + "29": 0, + "32": 33, + "36": 33, + "42": 0, + "47": 33, + "52": 33, + "62": 4 + }, + "branchHits": { + "24": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/controller/index.ts", + "lineHits": { + "1": 3, + "2": 3, + "4": 3, + "5": 3 + }, + "branchHits": {} + }, + { + "filename": "src/controller-x/VideoContextPlayerCoordinatorX.ts", + "lineHits": { + "43": 6, + "59": 60, + "99": 24, + "126": 0, + "132": 0, + "219": 4, + "233": 16, + "290": 3, + "338": 34, + "341": 17, + "362": 34, + "363": 0, + "422": 10, + "435": 8, + "444": 33, + "458": 6, + "466": 4, + "477": 4, + "507": 3, + "1": 3, + "2": 3, + "25": 3, + "26": 3, + "27": 3, + "28": 3, + "29": 3, + "31": 3, + "34": 3, + "57": 30, + "60": 30, + "61": 30, + "62": 30, + "68": 30, + "73": 30, + "74": 0, + "75": 0, + "79": 0, + "81": 30, + "83": 30, + "85": 30, + "86": 30, + "89": 30, + "90": 30, + "92": 30, + "100": 21, + "104": 21, + "109": 21, + "110": 0, + "117": 21, + "121": 21, + "123": 21, + "129": 21, + "136": 3, + "139": 11, + "141": 11, + "142": 0, + "143": 0, + "146": 5, + "147": 5, + "148": 10, + "150": 5, + "155": 5, + "158": 1, + "159": 1, + "160": 1, + "162": 1, + "164": 1, + "166": 1, + "168": 2, + "170": 2, + "174": 1, + "176": 2, + "178": 2, + "180": 1, + "182": 1, + "183": 1, + "185": 1, + "187": 1, + "189": 1, + "191": 0, + "193": 0, + "195": 0, + "198": 0, + "201": 0, + "203": 6, + "206": 3, + "209": 0, + "210": 2, + "212": 2, + "214": 2, + "216": 0, + "223": 1, + "224": 1, + "225": 1, + "226": 1, + "235": 1, + "238": 0, + "240": 0, + "243": 1, + "247": 1, + "250": 1, + "252": 1, + "256": 1, + "259": 7, + "261": 0, + "262": 7, + "264": 7, + "265": 7, + "269": 0, + "273": 0, + "274": 1, + "276": 1, + "278": 1, + "280": 1, + "282": 1, + "284": 1, + "286": 1, + "291": 0, + "293": 0, + "299": 3, + "303": 21, + "306": 21, + "307": 21, + "310": 7, + "311": 7, + "313": 0, + "316": 0, + "320": 0, + "321": 0, + "325": 0, + "328": 21, + "333": 21, + "334": 21, + "335": 21, + "337": 4, + "339": 17, + "350": 0, + "354": 0, + "355": 0, + "356": 0, + "360": 17, + "368": 4, + "371": 21, + "373": 21, + "374": 21, + "376": 21, + "379": 21, + "386": 0, + "390": 21, + "391": 21, + "392": 21, + "393": 21, + "394": 21, + "397": 21, + "398": 21, + "400": 21, + "402": 21, + "412": 0, + "415": 21, + "416": 21, + "424": 7, + "428": 7, + "430": 7, + "436": 5, + "438": 5, + "446": 0, + "447": 0, + "450": 0, + "451": 0, + "453": 30, + "454": 30, + "455": 30, + "459": 3, + "461": 2, + "462": 2, + "467": 1, + "471": 1, + "478": 0, + "479": 1, + "482": 1, + "483": 0, + "486": 1, + "487": 0, + "491": 0, + "493": 0, + "495": 0, + "499": 1, + "508": 0, + "510": 3 + }, + "branchHits": { + "110": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "139": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "182": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "209": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "238": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "240": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "261": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "273": { + "covered": 7, + "missed": 0, + "available": 7 + }, + "311": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "320": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "373": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "376": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "386": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "400": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "478": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "483": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "487": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "491": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "493": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "495": { + "covered": 3, + "missed": 1, + "available": 4 + } + } + }, + { + "filename": "src/controller-x/VideoContextPlayerCoordinatorXObserver.ts", + "lineHits": { + "5": 6, + "9": 3, + "13": 4, + "17": 7, + "24": 3, + "26": 4, + "30": 3, + "32": 3, + "34": 4, + "38": 3, + "40": 3, + "42": 3, + "44": 4, + "48": 3, + "50": 3, + "52": 3, + "54": 4, + "58": 4, + "62": 4, + "66": 4, + "70": 4, + "74": 3, + "76": 3, + "78": 3, + "2": 3, + "10": 3, + "14": 1, + "18": 4, + "20": 2, + "27": 1, + "35": 1, + "45": 1, + "55": 1, + "59": 1, + "63": 1, + "67": 1, + "71": 1, + "79": 3 + }, + "branchHits": {} + }, + { + "filename": "src/controller-x/index.ts", + "lineHits": { + "1": 3, + "2": 3 + }, + "branchHits": {} + }, + { + "filename": "src/events/InternalPlaybackObserver.ts", + "lineHits": { + "17": 22, + "21": 107, + "26": 100, + "32": 0, + "39": 0, + "42": 100, + "48": 89, + "57": 11, + "59": 0, + "63": 100, + "65": 89, + "69": 38, + "71": 27, + "75": 16, + "77": 5, + "81": 12, + "83": 1, + "87": 13, + "89": 2, + "93": 18, + "95": 7, + "99": 12, + "101": 1, + "106": 13, + "108": 2, + "113": 13, + "115": 2, + "120": 109, + "122": 98, + "126": 15, + "128": 4, + "132": 15, + "134": 4, + "138": 104, + "144": 93, + "149": 12, + "151": 1, + "156": 12, + "158": 1, + "163": 12, + "165": 1, + "170": 12, + "172": 1, + "177": 15, + "179": 4, + "184": 19, + "186": 8, + "191": 14, + "193": 3, + "22": 107, + "23": 107, + "31": 89, + "33": 0, + "47": 89, + "49": 0, + "58": 0, + "60": 0, + "64": 89, + "66": 0, + "70": 27, + "72": 0, + "76": 5, + "78": 0, + "82": 1, + "84": 0, + "88": 2, + "90": 0, + "94": 7, + "96": 0, + "100": 1, + "102": 0, + "107": 2, + "109": 0, + "114": 2, + "116": 0, + "121": 98, + "123": 0, + "127": 4, + "129": 0, + "133": 4, + "135": 0, + "143": 93, + "145": 0, + "150": 1, + "152": 0, + "157": 1, + "159": 0, + "164": 1, + "166": 0, + "171": 1, + "173": 0, + "178": 4, + "180": 0, + "185": 8, + "187": 0, + "192": 3, + "194": 0, + "197": 11 + }, + "branchHits": {} + }, + { + "filename": "src/events/PlaybackEventsObserver.ts", + "lineHits": { + "24": 22, + "28": 90, + "36": 100, + "48": 23, + "52": 17, + "56": 100, + "71": 11, + "75": 100, + "88": 38, + "97": 15, + "101": 12, + "105": 12, + "112": 13, + "116": 18, + "124": 12, + "129": 13, + "134": 13, + "142": 109, + "150": 15, + "157": 15, + "165": 104, + "177": 12, + "184": 12, + "191": 12, + "196": 12, + "204": 15, + "211": 19, + "219": 14, + "227": 472, + "13": 11, + "32": 90, + "33": 90, + "41": 89, + "49": 12, + "53": 6, + "61": 89, + "66": 89, + "72": 0, + "80": 89, + "81": 89, + "89": 27, + "90": 27, + "98": 4, + "99": 4, + "102": 1, + "103": 1, + "106": 1, + "107": 1, + "113": 2, + "114": 2, + "117": 7, + "118": 7, + "125": 1, + "126": 1, + "130": 2, + "131": 2, + "135": 2, + "136": 2, + "143": 98, + "144": 98, + "151": 4, + "152": 4, + "158": 4, + "159": 4, + "170": 93, + "171": 93, + "178": 1, + "179": 1, + "185": 1, + "186": 1, + "192": 1, + "193": 1, + "197": 1, + "198": 1, + "205": 4, + "206": 4, + "212": 8, + "213": 8, + "220": 3, + "221": 3, + "228": 461, + "229": 461, + "231": 11 + }, + "branchHits": {} + }, + { + "filename": "src/events/TimeObservable.ts", + "lineHits": { + "4": 26, + "8": 202, + "13": 101, + "14": 0, + "17": 0, + "22": 13, + "27": 0, + "31": 0, + "41": 24, + "46": 11, + "9": 101, + "10": 101, + "15": 0, + "18": 0, + "26": 0, + "28": 0, + "29": 0, + "30": 0, + "33": 0, + "34": 0, + "36": 0, + "45": 11, + "47": 0, + "48": 0, + "52": 0, + "53": 0, + "57": 13 + }, + "branchHits": {} + }, + { + "filename": "src/events/index.ts", + "lineHits": { + "1": 11, + "2": 11, + "4": 11, + "5": 11 + }, + "branchHits": {} + }, + { + "filename": "src/player/BetamaxPlayer.ts", + "lineHits": { + "25": 20, + "31": 14, + "277": 24, + "283": 890, + "288": 456, + "47": 26, + "58": 34, + "74": 34, + "76": 0, + "87": 10, + "95": 10, + "108": 10, + "121": 11, + "134": 10, + "146": 10, + "154": 11, + "162": 10, + "168": 10, + "179": 10, + "191": 0, + "203": 0, + "216": 0, + "228": 10, + "232": 27, + "244": 13, + "252": 11, + "256": 34, + "266": 459, + "8": 10, + "9": 10, + "15": 10, + "17": 10, + "35": 14, + "36": 14, + "37": 14, + "38": 14, + "48": 16, + "62": 17, + "64": 17, + "69": 17, + "77": 0, + "89": 0, + "91": 0, + "97": 0, + "99": 0, + "110": 0, + "112": 0, + "122": 1, + "124": 0, + "126": 1, + "136": 0, + "138": 0, + "148": 0, + "150": 0, + "156": 1, + "157": 1, + "158": 1, + "164": 0, + "170": 0, + "181": 0, + "183": 0, + "193": 0, + "195": 0, + "205": 0, + "207": 0, + "218": 0, + "220": 0, + "229": 0, + "233": 0, + "234": 2, + "238": 2, + "239": 2, + "240": 2, + "246": 3, + "247": 3, + "248": 3, + "253": 1, + "261": 17, + "267": 442, + "270": 17, + "271": 17, + "273": 0, + "278": 0, + "279": 1, + "284": 6, + "285": 6, + "293": 442, + "295": 10 + }, + "branchHits": { + "35": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "229": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "233": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "238": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "278": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/player/BetamaxPlayerFactory.ts", + "lineHits": { + "6": 20, + "9": 68, + "15": 78, + "2": 10, + "3": 10, + "12": 68, + "16": 68, + "19": 68, + "21": 10 + }, + "branchHits": {} + }, + { + "filename": "src/player/PlaybackSession.ts", + "lineHits": { + "50": 20, + "71": 90, + "165": 182, + "170": 2, + "177": 179, + "179": 102, + "90": 91, + "136": 100, + "146": 103, + "185": 178, + "189": 178, + "301": 0, + "310": 0, + "324": 89, + "334": 178, + "339": 979, + "343": 445, + "349": 14, + "351": 44, + "355": 20, + "361": 10, + "365": 15, + "392": 178, + "443": 0, + "458": 14, + "485": 99, + "501": 11, + "514": 14, + "525": 11, + "529": 23, + "558": 29, + "567": 24, + "571": 25, + "581": 15, + "596": 14, + "608": 37, + "619": 10, + "623": 110, + "714": 18, + "719": 16, + "762": 11, + "768": 12, + "782": 13, + "803": 195, + "840": 11, + "844": 180, + "849": 180, + "860": 16, + "869": 221, + "875": 18, + "879": 4, + "885": 18, + "922": 13, + "939": 11, + "952": 101, + "965": 1572, + "969": 784, + "983": 11, + "987": 11, + "991": 19, + "1026": 106, + "1035": 16, + "1040": 18, + "1076": 10, + "1123": 16, + "1134": 11, + "1138": 100, + "1142": 0, + "1146": 0, + "1150": 0, + "1154": 2, + "1158": 0, + "1": 10, + "7": 10, + "8": 10, + "9": 10, + "10": 10, + "11": 10, + "33": 10, + "34": 10, + "42": 10, + "44": 10, + "45": 10, + "48": 10, + "75": 90, + "76": 90, + "77": 90, + "78": 90, + "83": 1, + "87": 1, + "92": 1, + "96": 1, + "97": 1, + "101": 90, + "111": 90, + "116": 90, + "128": 90, + "143": 90, + "150": 1, + "153": 1, + "156": 1, + "159": 1, + "162": 89, + "167": 182, + "169": 92, + "171": 2, + "172": 0, + "181": 0, + "191": 89, + "195": 89, + "199": 89, + "201": 89, + "204": 89, + "208": 89, + "212": 89, + "216": 1, + "220": 1, + "222": 1, + "229": 1, + "232": 89, + "233": 89, + "235": 89, + "236": 89, + "241": 89, + "243": 0, + "245": 89, + "247": 0, + "253": 0, + "259": 89, + "266": 89, + "267": 89, + "268": 89, + "269": 89, + "271": 89, + "273": 89, + "276": 89, + "283": 89, + "286": 89, + "290": 89, + "295": 89, + "300": 89, + "302": 0, + "308": 0, + "312": 0, + "313": 0, + "314": 0, + "316": 0, + "325": 89, + "328": 89, + "329": 89, + "330": 89, + "335": 89, + "340": 890, + "344": 356, + "350": 4, + "352": 40, + "356": 16, + "362": 0, + "368": 3, + "369": 3, + "370": 0, + "374": 2, + "375": 2, + "383": 1, + "384": 1, + "393": 89, + "409": 0, + "410": 0, + "411": 0, + "413": 0, + "426": 10, + "428": 0, + "431": 0, + "434": 3, + "436": 3, + "437": 0, + "440": 0, + "441": 0, + "442": 0, + "444": 0, + "445": 0, + "449": 0, + "451": 2, + "454": 1, + "460": 0, + "465": 1, + "471": 0, + "476": 2, + "477": 0, + "479": 2, + "480": 2, + "486": 89, + "487": 89, + "488": 0, + "489": 1, + "492": 1, + "494": 89, + "502": 1, + "505": 1, + "507": 1, + "515": 4, + "516": 4, + "517": 4, + "522": 4, + "526": 1, + "530": 0, + "532": 1, + "535": 12, + "537": 12, + "538": 12, + "540": 12, + "549": 12, + "550": 12, + "553": 2, + "554": 2, + "559": 19, + "560": 0, + "561": 16, + "562": 16, + "564": 19, + "572": 0, + "574": 6, + "582": 5, + "583": 5, + "590": 4, + "592": 1, + "597": 4, + "601": 4, + "602": 4, + "604": 2, + "609": 27, + "610": 27, + "611": 27, + "612": 27, + "620": 0, + "625": 99, + "639": 100, + "640": 100, + "642": 0, + "643": 6, + "646": 0, + "647": 6, + "650": 100, + "652": 100, + "653": 100, + "654": 100, + "657": 0, + "663": 2, + "664": 2, + "665": 2, + "668": 100, + "671": 0, + "677": 2, + "680": 100, + "683": 100, + "684": 0, + "693": 2, + "695": 98, + "698": 0, + "699": 84, + "701": 100, + "711": 100, + "715": 0, + "716": 0, + "721": 1, + "723": 7, + "726": 2, + "742": 4, + "749": 4, + "755": 5, + "763": 1, + "771": 1, + "774": 1, + "775": 1, + "784": 3, + "787": 3, + "793": 3, + "806": 93, + "808": 6, + "810": 93, + "816": 93, + "823": 0, + "827": 4, + "833": 92, + "841": 1, + "846": 0, + "850": 90, + "851": 90, + "853": 90, + "854": 90, + "861": 6, + "862": 6, + "863": 6, + "864": 6, + "865": 6, + "866": 6, + "870": 211, + "871": 0, + "877": 2, + "880": 2, + "882": 6, + "887": 8, + "888": 8, + "892": 1, + "895": 7, + "896": 7, + "897": 7, + "899": 4, + "900": 4, + "909": 2, + "912": 0, + "915": 0, + "919": 7, + "927": 0, + "929": 3, + "930": 0, + "933": 3, + "936": 0, + "941": 0, + "946": 1, + "949": 0, + "954": 1, + "955": 1, + "957": 90, + "958": 90, + "960": 91, + "961": 91, + "962": 91, + "966": 1562, + "970": 774, + "971": 774, + "972": 774, + "975": 563, + "978": 774, + "984": 1, + "988": 1, + "992": 9, + "995": 0, + "997": 9, + "999": 2, + "1005": 1, + "1008": 9, + "1013": 9, + "1014": 9, + "1021": 9, + "1023": 9, + "1029": 96, + "1030": 96, + "1031": 96, + "1032": 96, + "1036": 6, + "1037": 6, + "1041": 8, + "1042": 8, + "1046": 8, + "1048": 8, + "1052": 8, + "1054": 0, + "1055": 0, + "1056": 0, + "1058": 0, + "1064": 8, + "1069": 0, + "1073": 8, + "1078": 0, + "1084": 10, + "1085": 4, + "1086": 0, + "1087": 0, + "1089": 4, + "1091": 4, + "1093": 4, + "1094": 4, + "1099": 4, + "1100": 0, + "1101": 0, + "1102": 0, + "1105": 8, + "1106": 0, + "1108": 0, + "1112": 0, + "1115": 4, + "1117": 4, + "1118": 4, + "1119": 4, + "1125": 6, + "1135": 1, + "1139": 90, + "1143": 0, + "1147": 0, + "1151": 0, + "1155": 2, + "1159": 0, + "1161": 10 + }, + "branchHits": { + "75": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "167": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "172": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "243": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "247": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "370": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "409": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "410": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "411": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "413": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "428": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "440": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "460": { + "covered": 4, + "missed": 0, + "available": 4 + }, + "471": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "477": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "488": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "530": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "535": { + "covered": 3, + "missed": 0, + "available": 3 + }, + "560": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "572": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "609": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "610": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "642": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "646": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "657": { + "covered": 3, + "missed": 0, + "available": 3 + }, + "671": { + "covered": 5, + "missed": 0, + "available": 5 + }, + "683": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "684": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "698": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "715": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "808": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "823": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "870": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "871": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "880": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "912": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "927": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "930": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "941": { + "covered": 4, + "missed": 0, + "available": 4 + }, + "966": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "997": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "1086": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "1087": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "1101": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "1159": { + "covered": 0, + "missed": 2, + "available": 2 + } + } + }, + { + "filename": "src/player/PlaybackSessionFactory.ts", + "lineHits": { + "10": 20, + "13": 68, + "19": 27, + "70": 17, + "2": 10, + "3": 10, + "4": 10, + "16": 68, + "23": 17, + "25": 0, + "33": 0, + "34": 0, + "35": 0, + "36": 0, + "37": 0, + "38": 0, + "39": 0, + "42": 0, + "53": 0, + "62": 0, + "63": 0, + "68": 17, + "71": 0, + "72": 17, + "78": 10 + }, + "branchHits": { + "23": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "33": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "62": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/player/index.ts", + "lineHits": { + "1": 10, + "2": 10, + "3": 10, + "4": 10, + "5": 10, + "6": 10 + }, + "branchHits": {} + }, + { + "filename": "src/player/player.ts", + "lineHits": { + "9": 24, + "12": 89, + "16": 101, + "18": 89, + "25": 198, + "29": 101, + "37": 12, + "41": 12, + "45": 12, + "49": 12, + "53": 38, + "57": 368, + "61": 28, + "65": 12, + "69": 12, + "73": 12, + "1": 12, + "4": 12, + "5": 12, + "13": 89, + "19": 0, + "22": 89, + "26": 186, + "34": 89, + "38": 0, + "42": 0, + "46": 0, + "50": 0, + "54": 26, + "58": 356, + "62": 16, + "66": 0, + "70": 0, + "74": 0, + "76": 12 + }, + "branchHits": {} + }, + { + "filename": "src/player/shaka.ts", + "lineHits": { + "2": 13, + "5": 13, + "6": 0, + "7": 0, + "10": 13 + }, + "branchHits": { + "6": { + "covered": 2, + "missed": 1, + "available": 3 + } + } + }, + { + "filename": "src/plugins/SPManifestParser.ts", + "lineHits": { + "26": 5, + "40": 14, + "45": 14, + "60": 10, + "67": 9, + "70": 9, + "75": 9, + "87": 14, + "94": 10, + "113": 14, + "152": 14, + "159": 13, + "173": 14, + "175": 44, + "186": 14, + "191": 23, + "211": 0, + "218": 0, + "226": 18, + "227": 26, + "242": 22, + "281": 14, + "289": 16, + "298": 13, + "313": 43, + "332": 0, + "366": 52, + "367": 28, + "368": 6, + "388": 68, + "399": 10, + "405": 4, + "461": 7944, + "467": 3974, + "468": 6, + "18": 9, + "27": 5, + "28": 5, + "29": 5, + "30": 5, + "31": 5, + "32": 5, + "33": 5, + "34": 5, + "35": 5, + "36": 5, + "41": 5, + "47": 0, + "50": 5, + "59": 5, + "61": 5, + "62": 5, + "88": 5, + "91": 5, + "92": 5, + "95": 5, + "98": 5, + "114": 5, + "117": 0, + "120": 0, + "122": 0, + "130": 5, + "134": 0, + "141": 5, + "153": 5, + "154": 5, + "156": 5, + "160": 13, + "174": 5, + "188": 5, + "189": 5, + "192": 18, + "194": 0, + "195": 13, + "196": 13, + "198": 5, + "199": 5, + "201": 0, + "206": 0, + "207": 0, + "210": 0, + "217": 0, + "225": 5, + "228": 13, + "231": 5, + "247": 13, + "249": 13, + "250": 0, + "252": 13, + "257": 0, + "260": 0, + "265": 0, + "284": 5, + "286": 0, + "299": 8, + "319": 34, + "322": 34, + "324": 13, + "327": 13, + "330": 34, + "333": 0, + "338": 0, + "343": 0, + "344": 0, + "345": 0, + "348": 0, + "369": 4, + "373": 2, + "376": 26, + "393": 34, + "395": 34, + "396": 34, + "400": 2, + "402": 0, + "406": 2, + "412": 2, + "417": 8, + "428": 26, + "430": 26, + "431": 26, + "433": 3972, + "439": 3972, + "440": 3972, + "441": 3972, + "446": 34, + "448": 34, + "469": 4, + "474": 2, + "477": 3972, + "487": 9 + }, + "branchHits": { + "194": { + "covered": 2, + "missed": 1, + "available": 3 + }, + "206": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "210": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "217": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "247": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "249": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "250": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "257": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "260": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "265": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "286": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "338": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "343": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "344": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "345": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "348": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/plugins/SpotifyVideoMediaExtension.ts", + "lineHits": { + "9": 16, + "12": 59, + "29": 10, + "34": 12, + "38": 10, + "2": 8, + "3": 8, + "13": 59, + "14": 59, + "18": 59, + "22": 59, + "23": 59, + "30": 2, + "31": 2, + "35": 4, + "42": 2, + "46": 8 + }, + "branchHits": {} + }, + { + "filename": "src/plugins/SpotifyVideoUrl.ts", + "lineHits": { + "7": 16, + "8": 39, + "1": 8, + "9": 31, + "11": 8 + }, + "branchHits": {} + }, + { + "filename": "src/plugins/TextParser.js", + "lineHits": { + "25": 0, + "37": 8, + "47": 8, + "57": 8, + "64": 8, + "78": 8, + "96": 8, + "116": 8, + "30": 0, + "33": 0, + "38": 0, + "48": 0, + "58": 0, + "65": 0, + "79": 0, + "80": 0, + "81": 0, + "84": 0, + "85": 0, + "98": 0, + "101": 0, + "103": 0, + "105": 0, + "117": 0, + "118": 0, + "120": 0, + "122": 0, + "130": 8 + }, + "branchHits": { + "80": { + "covered": 0, + "missed": 3, + "available": 3 + } + } + }, + { + "filename": "src/plugins/VttTextParser.js", + "lineHits": { + "26": 0, + "29": 8, + "37": 8, + "121": 8, + "144": 8, + "223": 8, + "260": 8, + "261": 0, + "285": 8, + "299": 8, + "315": 8, + "330": 8, + "362": 8, + "19": 8, + "20": 8, + "30": 0, + "38": 0, + "40": 0, + "41": 0, + "42": 0, + "45": 0, + "52": 0, + "60": 0, + "72": 0, + "76": 0, + "77": 0, + "78": 0, + "79": 0, + "80": 0, + "81": 0, + "84": 0, + "91": 0, + "92": 0, + "93": 0, + "95": 0, + "96": 0, + "101": 0, + "102": 0, + "103": 0, + "104": 0, + "106": 0, + "110": 0, + "122": 0, + "126": 0, + "129": 0, + "130": 0, + "132": 0, + "145": 0, + "148": 0, + "149": 0, + "154": 0, + "159": 0, + "162": 0, + "163": 0, + "165": 0, + "166": 0, + "170": 0, + "171": 0, + "172": 0, + "173": 0, + "175": 0, + "176": 0, + "183": 0, + "184": 0, + "187": 0, + "192": 0, + "195": 0, + "196": 0, + "199": 0, + "205": 0, + "206": 0, + "210": 0, + "212": 0, + "224": 0, + "225": 0, + "227": 0, + "229": 0, + "231": 0, + "237": 0, + "239": 0, + "242": 0, + "244": 0, + "247": 0, + "250": 0, + "262": 0, + "265": 0, + "270": 0, + "274": 0, + "277": 0, + "286": 0, + "288": 0, + "290": 0, + "300": 0, + "301": 0, + "302": 0, + "303": 0, + "304": 0, + "306": 0, + "316": 0, + "318": 0, + "320": 0, + "334": 0, + "335": 0, + "337": 0, + "338": 0, + "340": 0, + "343": 0, + "344": 0, + "346": 0, + "349": 0, + "352": 0, + "364": 0, + "366": 0, + "370": 0, + "371": 0, + "372": 0, + "373": 0, + "374": 0, + "375": 0, + "378": 0, + "385": 8, + "387": 8 + }, + "branchHits": { + "77": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "148": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "175": { + "covered": 0, + "missed": 3, + "available": 3 + }, + "301": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "303": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "370": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "374": { + "covered": 0, + "missed": 2, + "available": 2 + } + } + }, + { + "filename": "src/plugins/WidevineDownloader.ts", + "lineHits": { + "14": 14, + "21": 14, + "190": 12, + "34": 11, + "40": 4, + "44": 5, + "66": 4, + "69": 0, + "78": 9, + "90": 8, + "107": 11, + "137": 2, + "147": 1, + "165": 14, + "174": 8, + "2": 7, + "9": 7, + "10": 7, + "11": 7, + "12": 7, + "22": 7, + "26": 7, + "27": 7, + "28": 7, + "29": 7, + "36": 0, + "38": 4, + "41": 4, + "46": 0, + "49": 1, + "51": 0, + "59": 0, + "63": 0, + "64": 0, + "70": 0, + "80": 0, + "84": 1, + "86": 1, + "91": 1, + "92": 1, + "93": 1, + "94": 1, + "108": 4, + "111": 0, + "112": 2, + "113": 2, + "120": 1, + "121": 1, + "128": 1, + "129": 1, + "133": 0, + "136": 1, + "139": 1, + "140": 1, + "150": 0, + "152": 0, + "153": 0, + "166": 7, + "171": 0, + "172": 0, + "177": 7, + "179": 6, + "181": 3, + "182": 3, + "183": 3, + "184": 3, + "185": 3, + "187": 3, + "189": 3, + "191": 6, + "193": 3, + "198": 7, + "200": 3, + "201": 1, + "202": 1, + "203": 1, + "205": 1, + "207": 7 + }, + "branchHits": { + "59": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "80": { + "covered": 2, + "missed": 3, + "available": 5 + }, + "111": { + "covered": 4, + "missed": 6, + "available": 10 + }, + "179": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "184": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/plugins/index.ts", + "lineHits": { + "1": 8, + "2": 8, + "3": 8, + "4": 8, + "6": 8, + "7": 8, + "8": 8 + }, + "branchHits": {} + }, + { + "filename": "src/plugins/types.ts", + "lineHits": { + "19": 14, + "20": 7, + "21": 7, + "22": 7, + "23": 7, + "24": 7, + "25": 7, + "26": 7, + "27": 7, + "28": 7, + "29": 7, + "30": 7 + }, + "branchHits": { + "19": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/DefaultEventsCalculator.ts", + "lineHits": { + "34": 108, + "46": 7, + "95": 68, + "108": 17, + "116": 17, + "124": 92, + "145": 92, + "164": 41, + "168": 48, + "181": 41, + "185": 6, + "194": 41, + "198": 2, + "207": 42, + "215": 4, + "229": 41, + "233": 2, + "242": 42, + "246": 50, + "255": 44, + "259": 12, + "276": 42, + "284": 2, + "298": 41, + "302": 2, + "311": 42, + "315": 10, + "326": 42, + "334": 2, + "348": 43, + "352": 22, + "386": 42, + "398": 10, + "417": 45, + "475": 43, + "479": 83, + "483": 86, + "496": 41, + "500": 76, + "505": 86, + "522": 41, + "526": 76, + "531": 86, + "548": 39, + "1": 16, + "11": 16, + "12": 16, + "13": 16, + "37": 35, + "39": 0, + "40": 19, + "43": 0, + "51": 7, + "52": 7, + "55": 0, + "57": 0, + "58": 3, + "65": 3, + "69": 3, + "71": 3, + "78": 2, + "82": 3, + "84": 1, + "87": 1, + "89": 0, + "92": 7, + "99": 52, + "100": 52, + "109": 1, + "111": 1, + "113": 0, + "117": 1, + "119": 1, + "128": 76, + "129": 76, + "130": 0, + "132": 1, + "134": 0, + "136": 1, + "140": 1, + "142": 76, + "149": 76, + "150": 76, + "151": 0, + "152": 1, + "154": 0, + "155": 1, + "159": 1, + "161": 76, + "167": 25, + "184": 25, + "197": 25, + "210": 26, + "214": 26, + "216": 4, + "222": 0, + "232": 25, + "245": 26, + "258": 28, + "261": 0, + "262": 0, + "265": 0, + "266": 0, + "279": 26, + "283": 26, + "285": 2, + "291": 0, + "301": 25, + "314": 26, + "317": 0, + "329": 26, + "333": 26, + "335": 2, + "341": 0, + "351": 27, + "354": 5, + "365": 6, + "375": 0, + "389": 26, + "390": 26, + "397": 26, + "399": 5, + "400": 5, + "433": 86, + "435": 0, + "439": 5, + "444": 3, + "450": 9, + "451": 9, + "454": 9, + "457": 0, + "461": 4, + "466": 4, + "467": 4, + "468": 4, + "472": 29, + "478": 27, + "480": 83, + "499": 25, + "501": 76, + "507": 76, + "508": 76, + "525": 25, + "527": 76, + "533": 76, + "534": 76, + "551": 23 + }, + "branchHits": { + "39": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "55": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "57": { + "covered": 3, + "missed": 1, + "available": 4 + }, + "113": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "129": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "130": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "134": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "150": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "151": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "154": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "222": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "261": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "262": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "265": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "266": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "291": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "317": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "341": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "435": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "457": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/EventsTracker.ts", + "lineHits": { + "16": 32, + "19": 15, + "23": 17, + "40": 17, + "53": 17, + "63": 17, + "71": 17, + "86": 16, + "101": 28, + "117": 16, + "130": 21, + "145": 19, + "160": 25, + "170": 19, + "180": 21, + "195": 17, + "210": 17, + "225": 17, + "240": 17, + "248": 17, + "256": 17, + "271": 17, + "286": 17, + "301": 17, + "14": 16, + "20": 15, + "29": 1, + "44": 1, + "54": 1, + "64": 1, + "76": 1, + "91": 0, + "107": 12, + "121": 0, + "135": 5, + "150": 3, + "161": 9, + "171": 3, + "185": 5, + "200": 1, + "215": 1, + "230": 1, + "241": 1, + "249": 1, + "261": 1, + "276": 1, + "291": 1, + "302": 1, + "310": 16 + }, + "branchHits": {} + }, + { + "filename": "src/tracking/ProductStatesStreamingRulesProvider.ts", + "lineHits": { + "4": 32, + "9": 6, + "14": 2, + "17": 0, + "21": 20, + "10": 3, + "11": 3, + "12": 3, + "15": 2, + "22": 4, + "24": 16 + }, + "branchHits": {} + }, + { + "filename": "src/tracking/VideoPlaybackEvents.ts", + "lineHits": { + "1": 36, + "7": 36, + "17": 36, + "2": 18, + "3": 18, + "4": 18, + "8": 18, + "9": 18, + "10": 18, + "13": 18, + "18": 18, + "19": 18, + "20": 18, + "21": 18, + "22": 18, + "23": 18, + "24": 18, + "25": 18, + "26": 18, + "27": 18, + "28": 18, + "29": 18, + "30": 18, + "31": 18, + "32": 18, + "33": 18, + "34": 18, + "35": 18, + "36": 18, + "37": 18, + "38": 18, + "39": 18, + "40": 18, + "41": 18, + "42": 18, + "45": 18 + }, + "branchHits": { + "1": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "7": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "17": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/index.ts", + "lineHits": { + "1": 15, + "2": 15, + "3": 15, + "4": 15, + "5": 15, + "7": 15, + "8": 15, + "9": 15, + "11": 15, + "12": 15, + "13": 15, + "15": 15, + "16": 15 + }, + "branchHits": {} + }, + { + "filename": "src/tracking/types.ts", + "lineHits": { + "13": 32, + "95": 32, + "10": 16, + "14": 16, + "15": 16, + "16": 16, + "17": 16, + "18": 16, + "19": 16, + "20": 16, + "21": 16, + "22": 16, + "23": 16, + "24": 16, + "25": 16, + "26": 16, + "27": 16, + "28": 16, + "29": 16, + "30": 16, + "31": 16, + "32": 16, + "33": 16, + "34": 16, + "35": 16, + "36": 16, + "37": 16, + "38": 16, + "39": 16, + "40": 16, + "41": 16, + "42": 16, + "43": 16, + "44": 16, + "45": 16, + "46": 16, + "96": 16, + "97": 16, + "98": 16, + "99": 16, + "100": 16, + "101": 16, + "102": 16, + "103": 16, + "104": 16, + "105": 16, + "106": 16, + "107": 16, + "108": 16, + "109": 16, + "110": 16, + "111": 16, + "112": 16, + "113": 16, + "114": 16, + "115": 16, + "116": 16, + "117": 16, + "118": 16, + "119": 16, + "120": 16, + "121": 16, + "122": 16, + "123": 16, + "124": 16, + "125": 16, + "126": 16, + "127": 16, + "128": 16, + "129": 16, + "130": 16, + "131": 16, + "132": 16, + "133": 16, + "134": 16, + "135": 16, + "136": 16, + "137": 16, + "138": 16, + "139": 16, + "140": 16, + "141": 16, + "142": 16, + "143": 16, + "144": 16, + "145": 16, + "146": 16, + "147": 16, + "148": 16 + }, + "branchHits": { + "13": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "95": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/utils.ts", + "lineHits": { + "44": 64, + "109": 225, + "113": 1944, + "115": 1902, + "123": 514, + "128": 486, + "133": 91, + "138": 95, + "143": 239, + "148": 314, + "153": 167, + "158": 91, + "163": 91, + "168": 261, + "173": 167, + "178": 17, + "183": 139, + "188": 93, + "193": 475, + "198": 61, + "203": 95, + "208": 19, + "220": 1501, + "224": 484, + "228": 462, + "232": 900, + "236": 113, + "245": 96, + "250": 460, + "251": 56, + "254": 46, + "259": 158, + "261": 19, + "269": 78, + "2": 17, + "4": 17, + "7": 17, + "9": 17, + "11": 17, + "12": 17, + "13": 17, + "14": 17, + "15": 17, + "16": 17, + "17": 17, + "18": 17, + "19": 17, + "20": 17, + "21": 17, + "22": 17, + "23": 17, + "24": 17, + "26": 17, + "42": 17, + "47": 47, + "48": 47, + "53": 47, + "58": 47, + "63": 47, + "64": 47, + "69": 47, + "74": 47, + "75": 47, + "76": 47, + "77": 47, + "78": 47, + "100": 17, + "102": 17, + "104": 17, + "112": 208, + "126": 497, + "131": 469, + "136": 74, + "141": 78, + "146": 222, + "151": 297, + "156": 150, + "161": 74, + "166": 74, + "171": 244, + "176": 150, + "181": 0, + "186": 122, + "191": 76, + "196": 458, + "201": 44, + "206": 78, + "209": 2, + "211": 17, + "222": 497, + "226": 132, + "230": 110, + "234": 248, + "237": 96, + "239": 96, + "249": 79, + "258": 29, + "262": 19, + "263": 0, + "270": 0, + "271": 0, + "273": 61, + "274": 0, + "275": 0, + "277": 61 + }, + "branchHits": { + "209": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "222": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "226": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "230": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "234": { + "covered": 4, + "missed": 0, + "available": 4 + }, + "263": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "270": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "274": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/endvideo/EndVideoEventsCalculator.ts", + "lineHits": { + "39": 24, + "52": 11, + "71": 6, + "79": 14, + "88": 11, + "92": 19, + "120": 28, + "123": 36, + "145": 20, + "181": 36, + "190": 27, + "206": 25, + "223": 26, + "240": 26, + "258": 42, + "264": 48, + "272": 42, + "276": 2, + "285": 42, + "289": 2, + "298": 43, + "306": 50, + "328": 40, + "332": 46, + "341": 45, + "345": 4, + "356": 43, + "361": 50, + "371": 43, + "375": 89, + "380": 86, + "394": 43, + "398": 87, + "403": 90, + "416": 46, + "420": 91, + "425": 88, + "442": 40, + "1": 15, + "8": 15, + "9": 15, + "28": 15, + "36": 15, + "37": 15, + "41": 0, + "42": 5, + "44": 0, + "46": 0, + "48": 19, + "57": 8, + "61": 0, + "64": 0, + "66": 0, + "68": 3, + "76": 1, + "78": 5, + "81": 7, + "82": 7, + "89": 11, + "94": 11, + "95": 11, + "97": 8, + "99": 0, + "103": 0, + "105": 0, + "106": 1, + "110": 1, + "113": 3, + "114": 3, + "116": 4, + "127": 21, + "128": 0, + "129": 5, + "133": 5, + "134": 5, + "135": 5, + "140": 21, + "141": 21, + "142": 21, + "149": 5, + "151": 5, + "153": 5, + "157": 5, + "159": 0, + "160": 4, + "161": 0, + "162": 1, + "166": 1, + "167": 1, + "168": 1, + "169": 1, + "174": 0, + "175": 0, + "177": 5, + "178": 5, + "184": 21, + "191": 12, + "193": 12, + "211": 10, + "212": 10, + "215": 10, + "228": 11, + "229": 11, + "232": 11, + "241": 11, + "248": 11, + "249": 11, + "261": 27, + "265": 24, + "266": 0, + "275": 27, + "288": 27, + "301": 28, + "305": 28, + "308": 13, + "310": 0, + "312": 13, + "316": 0, + "320": 12, + "331": 25, + "344": 30, + "347": 0, + "359": 28, + "360": 28, + "363": 0, + "374": 28, + "376": 89, + "397": 28, + "399": 87, + "419": 31, + "421": 91, + "445": 25, + "446": 25, + "489": 25 + }, + "branchHits": { + "41": { + "covered": 2, + "missed": 2, + "available": 4 + }, + "105": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "128": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "159": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "161": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "174": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "266": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "310": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "316": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "347": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "363": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/endvideo/EndVideoLoggerFactory.ts", + "lineHits": { + "11": 30, + "19": 14, + "32": 29, + "9": 15, + "26": 14, + "27": 14, + "28": 14, + "29": 14, + "30": 14, + "33": 14, + "41": 15 + }, + "branchHits": {} + }, + { + "filename": "src/tracking/endvideo/EndVideoLoggerTracker.ts", + "lineHits": { + "24": 30, + "38": 14, + "54": 14, + "62": 0, + "96": 0, + "139": 24, + "140": 18, + "141": 18, + "154": 40, + "155": 40, + "156": 40, + "166": 35, + "177": 38, + "1": 15, + "4": 15, + "15": 15, + "16": 15, + "17": 15, + "18": 15, + "21": 15, + "22": 15, + "35": 14, + "45": 14, + "46": 14, + "47": 14, + "48": 14, + "49": 14, + "50": 14, + "52": 14, + "53": 14, + "56": 0, + "57": 0, + "59": 0, + "60": 0, + "61": 0, + "64": 0, + "67": 0, + "73": 15, + "79": 11, + "85": 11, + "86": 0, + "90": 3, + "91": 16, + "94": 11, + "97": 0, + "102": 15, + "107": 2, + "108": 2, + "109": 0, + "111": 4, + "114": 15, + "115": 2, + "116": 2, + "118": 4, + "121": 15, + "122": 7, + "123": 7, + "124": 14, + "127": 15, + "132": 4, + "133": 8, + "135": 3, + "143": 0, + "144": 0, + "145": 0, + "147": 9, + "148": 9, + "149": 9, + "158": 0, + "159": 0, + "161": 20, + "178": 23, + "179": 0, + "184": 15, + "187": 23, + "194": 3, + "197": 11, + "198": 18, + "201": 20, + "205": 40, + "208": 15, + "209": 19, + "218": 38, + "220": 15 + }, + "branchHits": { + "45": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "86": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "109": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "179": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/endvideo/EndVideoReasonEnd.ts", + "lineHits": { + "1": 30, + "2": 15, + "3": 15, + "4": 15, + "5": 15, + "6": 15, + "7": 15, + "8": 15, + "11": 15 + }, + "branchHits": { + "1": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/endvideo/EndVideoTracker.ts", + "lineHits": { + "24": 30, + "64": 13, + "78": 28, + "110": 26, + "118": 27, + "131": 79, + "237": 21, + "255": 44, + "287": 28, + "295": 22, + "355": 20, + "364": 28, + "417": 28, + "419": 17, + "430": 28, + "438": 24, + "458": 25, + "1": 15, + "2": 15, + "15": 15, + "20": 15, + "71": 13, + "72": 13, + "73": 13, + "74": 13, + "75": 13, + "79": 13, + "80": 13, + "81": 13, + "82": 13, + "83": 13, + "84": 13, + "85": 13, + "86": 13, + "87": 13, + "88": 13, + "89": 13, + "90": 13, + "91": 13, + "92": 13, + "93": 13, + "94": 13, + "95": 13, + "96": 13, + "97": 13, + "98": 13, + "99": 13, + "100": 13, + "101": 13, + "102": 13, + "103": 13, + "104": 13, + "105": 13, + "106": 13, + "108": 13, + "109": 13, + "112": 0, + "113": 0, + "115": 13, + "116": 13, + "117": 13, + "120": 14, + "123": 14, + "128": 13, + "133": 5, + "135": 64, + "136": 0, + "138": 0, + "139": 55, + "140": 55, + "141": 55, + "142": 55, + "143": 0, + "146": 0, + "147": 6, + "149": 0, + "150": 1, + "154": 10, + "157": 10, + "166": 64, + "167": 64, + "168": 64, + "170": 0, + "171": 7, + "174": 2, + "177": 2, + "178": 2, + "184": 3, + "185": 3, + "186": 3, + "190": 0, + "191": 0, + "193": 0, + "194": 0, + "197": 64, + "199": 21, + "201": 64, + "203": 2, + "204": 0, + "205": 1, + "209": 3, + "215": 3, + "218": 0, + "219": 10, + "226": 10, + "227": 10, + "229": 10, + "230": 0, + "231": 10, + "233": 10, + "238": 6, + "245": 6, + "247": 3, + "248": 3, + "250": 3, + "251": 3, + "262": 27, + "264": 16, + "269": 27, + "272": 17, + "274": 10, + "283": 27, + "288": 13, + "289": 13, + "290": 13, + "291": 13, + "292": 13, + "293": 13, + "297": 6, + "299": 1, + "301": 2, + "303": 13, + "304": 13, + "305": 17, + "306": 17, + "307": 17, + "310": 12, + "311": 12, + "313": 5, + "314": 5, + "317": 10, + "319": 7, + "320": 7, + "322": 17, + "324": 13, + "328": 0, + "330": 10, + "333": 4, + "335": 6, + "339": 13, + "342": 6, + "347": 13, + "357": 0, + "358": 1, + "360": 4, + "365": 13, + "366": 13, + "367": 0, + "369": 13, + "377": 0, + "405": 0, + "418": 13, + "421": 0, + "422": 0, + "425": 1, + "432": 0, + "436": 12, + "440": 0, + "441": 0, + "442": 0, + "444": 12, + "445": 12, + "446": 12, + "448": 12, + "452": 0, + "455": 1, + "460": 0, + "462": 10, + "463": 10, + "464": 10, + "466": 15 + }, + "branchHits": { + "136": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "138": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "143": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "146": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "149": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "166": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "168": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "170": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "190": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "193": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "204": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "218": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "226": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "227": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "230": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "357": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "360": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "367": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "377": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "405": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/session/PlaybackSessionTracker.ts", + "lineHits": { + "7": 32, + "9": 1, + "14": 17, + "1": 16, + "10": 1, + "11": 1, + "19": 1, + "21": 1, + "22": 1, + "25": 16 + }, + "branchHits": { + "10": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/tracking/session/VideoPlaybackSessionLogger.ts", + "lineHits": { + "6": 30, + "9": 0, + "12": 15, + "13": 0, + "3": 15, + "4": 15, + "10": 0, + "14": 0, + "15": 0, + "18": 15 + }, + "branchHits": {} + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-3.json b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-3.json new file mode 100644 index 0000000000..625dcde1a0 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-3.json @@ -0,0 +1,297 @@ +[ + { + "filename": "src/atoms/BoxShadow/box-shadow.ts", + "lineHits": { + "4": 5 + }, + "branchHits": {} + }, + { + "filename": "src/atoms/Button/button.tsx", + "lineHits": { + "17": 41, + "34": 41, + "70": 82, + "72": 82, + "74": 41, + "31": 41, + "32": 41, + "35": 0, + "36": 0, + "37": 0, + "38": 0, + "39": 0, + "40": 0, + "41": 0, + "42": 0, + "43": 0, + "47": 41, + "68": 10, + "75": 41 + }, + "branchHits": { + "35": { + "covered": 0, + "missed": 4, + "available": 4 + }, + "70": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "72": { + "covered": 3, + "missed": 0, + "available": 3 + }, + "75": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/atoms/Container/components.ts", + "lineHits": { + "5": 70, + "19": 70, + "20": 70, + "23": 70, + "4": 13, + "13": 13 + }, + "branchHits": {} + }, + { + "filename": "src/atoms/Container/container.tsx", + "lineHits": { + "12": 35, + "20": 35 + }, + "branchHits": {} + }, + { + "filename": "src/atoms/DownArrow/down-arrow.tsx", + "lineHits": { + "3": 28, + "4": 28 + }, + "branchHits": {} + }, + { + "filename": "src/molecules/Hero/components.ts", + "lineHits": { + "26": 20, + "28": 10, + "94": 10, + "103": 20, + "121": 10, + "182": 20, + "203": 5, + "218": 5, + "236": 10, + "241": 10, + "249": 8, + "254": 4, + "264": 4, + "277": 8, + "282": 4, + "294": 8, + "299": 6, + "25": 2, + "29": 10, + "37": 3, + "39": 3, + "40": 3, + "42": 3, + "43": 1, + "44": 1, + "45": 0, + "46": 0, + "48": 1, + "52": 0, + "53": 1, + "57": 0, + "59": 1, + "60": 1, + "63": 3, + "95": 10, + "112": 2, + "122": 10, + "125": 9, + "134": 0, + "151": 1, + "170": 2, + "181": 2, + "194": 2, + "204": 5, + "207": 3, + "209": 0, + "211": 1, + "213": 1, + "219": 5, + "222": 3, + "224": 0, + "226": 0, + "228": 1, + "230": 1, + "245": 2, + "255": 4, + "258": 3, + "260": 1, + "265": 4, + "268": 2, + "270": 1, + "272": 1, + "283": 4, + "286": 2, + "288": 1, + "290": 1, + "298": 2, + "304": 2, + "311": 2, + "319": 2, + "329": 2, + "361": 2, + "371": 2 + }, + "branchHits": { + "29": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "39": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "42": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "43": { + "covered": 3, + "missed": 1, + "available": 4 + }, + "45": { + "covered": 0, + "missed": 4, + "available": 4 + }, + "48": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "53": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "59": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "60": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "95": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "122": { + "covered": 2, + "missed": 2, + "available": 4 + }, + "182": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "204": { + "covered": 4, + "missed": 1, + "available": 5 + }, + "219": { + "covered": 4, + "missed": 2, + "available": 6 + }, + "236": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "241": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "249": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "255": { + "covered": 3, + "missed": 0, + "available": 3 + }, + "265": { + "covered": 4, + "missed": 0, + "available": 4 + }, + "277": { + "covered": 1, + "missed": 0, + "available": 1 + }, + "283": { + "covered": 4, + "missed": 0, + "available": 4 + }, + "294": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "299": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/molecules/Hero/hero.tsx", + "lineHits": { + "40": 10, + "72": 0, + "127": 3, + "56": 10, + "58": 10, + "73": 0, + "128": 3 + }, + "branchHits": { + "73": { + "covered": 0, + "missed": 2, + "available": 2 + } + } + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-4.json b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-4.json new file mode 100644 index 0000000000..eada26d5ce --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-4.json @@ -0,0 +1,351 @@ +[ + { + "filename": "Sources/TDSOptions.m", + "lineHits": { + "32": 0, + "33": 0, + "34": 0, + "35": 0, + "36": 0, + "37": 0, + "38": 0, + "45": 0, + "46": 0, + "47": 0, + "48": 0, + "49": 0, + "50": 0, + "51": 0, + "52": 0, + "53": 0, + "54": 0, + "55": 0, + "56": 0, + "57": 0, + "58": 0, + "61": 0, + "62": 0, + "63": 0, + "66": 0, + "67": 0, + "68": 0, + "69": 0, + "70": 0, + "71": 0, + "72": 0, + "73": 0, + "74": 0, + "75": 0, + "76": 0, + "77": 0 + }, + "branchHits": {} + }, + { + "filename": "Sources/TDSUserLock.m", + "lineHits": { + "17": 0, + "18": 0, + "19": 0, + "20": 0, + "21": 0, + "22": 0, + "23": 0, + "24": 0, + "25": 0, + "26": 0, + "27": 0, + "28": 0, + "29": 0, + "30": 0, + "31": 0, + "32": 0, + "33": 0, + "34": 0, + "35": 0, + "36": 0, + "37": 0, + "38": 0, + "39": 0, + "40": 0, + "43": 0, + "44": 0, + "45": 0, + "46": 0, + "47": 0, + "48": 0, + "49": 0, + "58": 0, + "59": 0, + "60": 0, + "61": 0, + "62": 0, + "63": 0, + "64": 0, + "65": 0, + "66": 0, + "67": 0, + "68": 0, + "69": 0, + "70": 0 + }, + "branchHits": {} + }, + { + "filename": "Sources/TDSUserManager.m", + "lineHits": { + "24": 0, + "25": 0, + "26": 0, + "27": 0, + "28": 0, + "29": 0, + "30": 0, + "31": 0, + "34": 0, + "35": 0, + "36": 0, + "37": 0, + "38": 0, + "39": 0, + "40": 0, + "49": 0, + "50": 0, + "51": 0, + "52": 0, + "53": 0, + "54": 0, + "55": 0, + "56": 0, + "57": 0, + "58": 0, + "59": 0, + "60": 0, + "61": 0, + "62": 0, + "63": 0, + "64": 0, + "65": 0, + "66": 0, + "67": 0, + "68": 0, + "69": 0, + "70": 0, + "74": 0, + "75": 0, + "76": 0, + "77": 0, + "78": 0, + "79": 0, + "80": 0, + "81": 0, + "82": 0, + "83": 0, + "84": 0, + "85": 0, + "86": 0, + "87": 0, + "88": 0, + "89": 0, + "90": 0, + "91": 0, + "92": 0, + "93": 0, + "94": 0, + "101": 0, + "102": 0, + "103": 0, + "104": 0, + "105": 0, + "106": 0, + "107": 0, + "108": 0, + "109": 0, + "110": 0, + "111": 0, + "112": 0, + "113": 0, + "114": 0, + "115": 0, + "116": 0, + "117": 0, + "118": 0, + "119": 0, + "120": 0, + "121": 0, + "122": 0, + "123": 0, + "124": 0, + "125": 0, + "126": 0, + "127": 0, + "128": 0, + "129": 0, + "130": 0, + "131": 0, + "136": 0, + "137": 0, + "138": 0, + "139": 0, + "140": 0, + "141": 0, + "142": 0, + "143": 0, + "144": 0, + "145": 0, + "146": 0, + "147": 0, + "148": 0, + "149": 0, + "150": 0, + "151": 0, + "152": 0, + "153": 0, + "158": 0, + "159": 0, + "160": 0, + "161": 0, + "162": 0, + "163": 0, + "164": 0, + "165": 0, + "166": 0, + "167": 0, + "168": 0, + "169": 0, + "170": 0, + "171": 0, + "172": 0, + "173": 0, + "174": 0, + "175": 0, + "176": 0, + "177": 0, + "178": 0, + "179": 0, + "180": 0, + "181": 0, + "182": 0, + "183": 0, + "184": 0, + "189": 0, + "190": 0, + "191": 0, + "192": 0, + "193": 0, + "194": 0, + "195": 0, + "196": 0, + "197": 0, + "198": 0, + "199": 0, + "200": 0, + "201": 0, + "202": 0, + "203": 0, + "204": 0, + "205": 0, + "206": 0, + "207": 0, + "208": 0, + "209": 0, + "210": 0, + "211": 0, + "212": 0, + "213": 0, + "214": 0, + "215": 0, + "216": 0, + "217": 0, + "218": 0, + "219": 0, + "220": 0, + "221": 0, + "222": 0, + "223": 0, + "224": 0, + "225": 0, + "226": 0, + "227": 0, + "228": 0, + "229": 0, + "230": 0, + "231": 0, + "232": 0, + "233": 0, + "234": 0, + "235": 0, + "236": 0, + "239": 0, + "240": 0, + "241": 0, + "242": 0, + "243": 0, + "244": 0, + "245": 0, + "246": 0, + "247": 0, + "248": 0, + "249": 0, + "252": 0, + "253": 0, + "254": 0, + "255": 0, + "256": 0, + "257": 0, + "258": 0, + "259": 0, + "260": 0, + "261": 0, + "262": 0, + "263": 0, + "264": 0, + "265": 0, + "268": 0, + "269": 0, + "270": 0, + "271": 0, + "272": 0, + "273": 0, + "274": 0, + "275": 0, + "276": 0, + "277": 0, + "278": 0, + "279": 0, + "280": 0, + "281": 0, + "282": 0, + "283": 0, + "284": 0, + "287": 0, + "288": 0, + "289": 0, + "290": 0, + "291": 0, + "292": 0, + "293": 0, + "294": 0, + "295": 0, + "296": 0 + }, + "branchHits": {} + }, + { + "filename": "Sources/TDSUtil.m", + "lineHits": { + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0, + "13": 0, + "16": 0, + "17": 0, + "18": 0, + "19": 0, + "20": 0 + }, + "branchHits": {} + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-5.json b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-5.json new file mode 100644 index 0000000000..41ab6c084f --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-jsoncoverage-files-5.json @@ -0,0 +1,325 @@ +[ + { + "filename": "src/graphql/queries.ts", + "lineHits": { + "1": 3, + "3": 3, + "50": 3, + "65": 3, + "80": 3, + "92": 3, + "138": 3 + }, + "branchHits": {} + }, + { + "filename": "src/graphql/resolvers.ts", + "lineHits": { + "22": 17, + "28": 14, + "36": 16, + "27": 8, + "35": 8, + "43": 9, + "51": 15, + "56": 8, + "61": 8, + "50": 8, + "55": 8, + "60": 8, + "128": 7, + "139": 4, + "145": 8, + "160": 8, + "177": 7, + "185": 13, + "70": 12, + "78": 8, + "81": 8, + "84": 4, + "97": 12, + "103": 8, + "114": 8, + "144": 8, + "159": 8, + "176": 8, + "184": 8, + "1": 4, + "2": 4, + "3": 4, + "5": 4, + "6": 4, + "7": 4, + "8": 4, + "9": 4, + "10": 4, + "14": 4, + "21": 4, + "24": 17, + "32": 10, + "37": 12, + "42": 4, + "45": 9, + "47": 9, + "52": 11, + "57": 4, + "62": 4, + "67": 4, + "73": 8, + "83": 4, + "85": 4, + "86": 4, + "87": 4, + "88": 4, + "94": 4, + "95": 4, + "98": 4, + "104": 4, + "107": 4, + "115": 4, + "118": 4, + "119": 4, + "120": 0, + "124": 4, + "130": 7, + "132": 7, + "134": 7, + "136": 7, + "140": 4, + "151": 4, + "165": 4, + "166": 4, + "167": 4, + "168": 1, + "169": 4, + "178": 3, + "186": 9, + "187": 9, + "193": 2, + "194": 0 + }, + "branchHits": { + "86": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "119": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "166": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "167": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "193": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/graphql/router.ts", + "lineHits": { + "21": 1, + "22": 2, + "43": 21, + "53": 40, + "12": 3, + "14": 3, + "23": 1, + "26": 0, + "32": 1, + "33": 1, + "34": 1, + "37": 0, + "54": 11, + "55": 0, + "57": 11, + "58": 11, + "63": 11, + "64": 11, + "65": 10, + "66": 10, + "68": 1 + }, + "branchHits": { + "23": { + "covered": 3, + "missed": 0, + "available": 3 + }, + "26": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "32": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "55": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "65": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/graphql/schema.ts", + "lineHits": { + "9": 7, + "1": 3, + "2": 3, + "4": 3, + "5": 3, + "11": 0, + "13": 4, + "15": 4, + "16": 4, + "17": 4, + "19": 4 + }, + "branchHits": { + "11": { + "covered": 0, + "missed": 1, + "available": 1 + } + } + }, + { + "filename": "src/graphql/transforms.ts", + "lineHits": { + "9": 10, + "20": 5, + "13": 6, + "14": 2, + "15": 6, + "16": 1, + "17": 6, + "23": 1, + "24": 0, + "25": 1 + }, + "branchHits": { + "13": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "15": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "23": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + }, + { + "filename": "src/workers/primary-worker.js", + "lineHits": { + "16": 0, + "25": 0, + "49": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "8": 0, + "17": 0, + "21": 0, + "22": 0, + "23": 0, + "24": 0, + "27": 0, + "28": 0, + "31": 0, + "33": 0, + "34": 0, + "37": 0, + "38": 0, + "48": 0, + "51": 0, + "53": 0 + }, + "branchHits": { + "17": { + "covered": 0, + "missed": 1, + "available": 1 + }, + "21": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "49": { + "covered": 0, + "missed": 2, + "available": 2 + } + } + }, + { + "filename": "src/workers/secondary-worker.js", + "lineHits": { + "4": 0, + "13": 0, + "2": 0, + "5": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0, + "14": 0, + "15": 0, + "16": 0, + "18": 0, + "19": 0, + "22": 0, + "23": 0, + "24": 0, + "25": 0, + "26": 0, + "27": 0, + "29": 0 + }, + "branchHits": { + "5": { + "covered": 0, + "missed": 1, + "available": 1 + }, + "9": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "24": { + "covered": 0, + "missed": 2, + "available": 2 + } + } + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-1.txt b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-1.txt new file mode 100644 index 0000000000..bee95e0bca --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-1.txt @@ -0,0 +1,19 @@ +.dockerignore +.editorconfig +.gitattributes +.gitignore +.gitmodules +.idea/codeStyleSettings.xml +.idea/codeStyles/Project.xml +.idea/codeStyles/codeStyleConfig.xml +.idea/inspectionProfiles/Spotify.xml +.idea/inspectionProfiles/profiles_settings.xml +.mention-bot +.ownership-bot +.slack_mentioner +CONTRIBUTING.md +README.md +WORKSPACE +src/main/scala/com/example/Main.scala +src/main/scala/com/example/Other.scala +src/main/scala/com/example/utils/Util.scala diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-2.txt b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-2.txt new file mode 100644 index 0000000000..b8d13fc792 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-2.txt @@ -0,0 +1,49 @@ +.dockerignore +.editorconfig +.gitattributes +.gitignore +.gitmodules +CONTRIBUTING.md +README.md +WORKSPACE +src/index.ts +src/utils.ts +src/controller/VideoContextPlayerCoordinator.ts +src/controller/VideoContextPlayerCoordinatorFactory.ts +src/controller/index.ts +src/controller-x/VideoContextPlayerCoordinatorX.ts +src/controller-x/VideoContextPlayerCoordinatorXObserver.ts +src/controller-x/index.ts +src/events/InternalPlaybackObserver.ts +src/events/PlaybackEventsObserver.ts +src/events/TimeObservable.ts +src/events/index.ts +src/player/BetamaxPlayer.ts +src/player/BetamaxPlayerFactory.ts +src/player/PlaybackSession.ts +src/player/PlaybackSessionFactory.ts +src/player/index.ts +src/player/player.ts +src/player/shaka.ts +src/plugins/SPManifestParser.ts +src/plugins/SpotifyVideoMediaExtension.ts +src/plugins/SpotifyVideoUrl.ts +src/plugins/TextParser.js +src/plugins/VttTextParser.js +src/plugins/WidevineDownloader.ts +src/plugins/index.ts +src/plugins/types.ts +src/tracking/DefaultEventsCalculator.ts +src/tracking/EventsTracker.ts +src/tracking/ProductStatesStreamingRulesProvider.ts +src/tracking/VideoPlaybackEvents.ts +src/tracking/index.ts +src/tracking/types.ts +src/tracking/utils.ts +src/tracking/endvideo/EndVideoEventsCalculator.ts +src/tracking/endvideo/EndVideoLoggerFactory.ts +src/tracking/endvideo/EndVideoLoggerTracker.ts +src/tracking/endvideo/EndVideoReasonEnd.ts +src/tracking/endvideo/EndVideoTracker.ts +src/tracking/session/PlaybackSessionTracker.ts +src/tracking/session/VideoPlaybackSessionLogger.ts diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-3.txt b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-3.txt new file mode 100644 index 0000000000..fb2d4096ed --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-3.txt @@ -0,0 +1,29 @@ +.dockerignore +.editorconfig +.gitattributes +.gitignore +.gitmodules +.idea/codeStyleSettings.xml +.idea/codeStyles/Project.xml +.idea/codeStyles/codeStyleConfig.xml +.idea/inspectionProfiles/Spotify.xml +.idea/inspectionProfiles/profiles_settings.xml +.mention-bot +.ownership-bot +.slack_mentioner +CONTRIBUTING.md +README.md +WORKSPACE +src/index.ts +src/atoms/BoxShadow/box-shadow.ts +src/atoms/BoxShadow/index.ts +src/atoms/Button/button.tsx +src/atoms/Button/index.ts +src/atoms/Container/components.ts +src/atoms/Container/container.tsx +src/atoms/Container/index.ts +src/atoms/DownArrow/down-arrow.tsx +src/atoms/DownArrow/index.ts +src/molecules/Hero/components.ts +src/molecules/Hero/hero.tsx +src/molecules/Hero/index.ts diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-4.txt b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-4.txt new file mode 100644 index 0000000000..4e0e3699af --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-4.txt @@ -0,0 +1,20 @@ +.dockerignore +.editorconfig +.gitattributes +.gitignore +.gitmodules +.idea/codeStyleSettings.xml +.idea/codeStyles/Project.xml +.idea/codeStyles/codeStyleConfig.xml +.idea/inspectionProfiles/Spotify.xml +.idea/inspectionProfiles/profiles_settings.xml +.mention-bot +.ownership-bot +.slack_mentioner +CONTRIBUTING.md +README.md +WORKSPACE +Sources/TDSOptions.m +Sources/TDSUserLock.m +Sources/TDSUserManager.m +Sources/TDSUtil.m diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-5.txt b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-5.txt new file mode 100644 index 0000000000..d117a9885a --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-sourcefiles-5.txt @@ -0,0 +1,26 @@ +.editorconfig +.eslintignore +.eslintrc.js +.gitignore +.npmrc +.nvmrc +Dockerfile +README.md +babel.config.js +nodemon.json +package.json +prettier.config.js +service-info.yaml +src/graphql/__tests__/resolvers.test.ts +src/graphql/__tests__/transforms.test.ts +src/graphql/queries.ts +src/graphql/resolvers.ts +src/graphql/router.ts +src/graphql/schema.ts +src/graphql/transforms.ts +src/start.ts +src/sysmodel_component.ts +src/workers/primary-worker.js +src/workers/secondary-worker.js +tsconfig.json +yarn.lock diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-1.xml b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-1.xml new file mode 100644 index 0000000000..37e2c8c560 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-1.xml @@ -0,0 +1,479 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-2.xml b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-2.xml new file mode 100644 index 0000000000..9e94385cbc --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-2.xml @@ -0,0 +1,5721 @@ + + + + . + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-3.xml b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-3.xml new file mode 100644 index 0000000000..176152791b --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-3.xml @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-4.xml b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-4.xml new file mode 100644 index 0000000000..36e36da3be --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-4.xml @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-5.xml b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-5.xml new file mode 100644 index 0000000000..5bc8e6e45d --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/cobertura-testdata-5.xml @@ -0,0 +1,441 @@ + + + + . + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-jsoncoverage-files-1.json b/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-jsoncoverage-files-1.json new file mode 100644 index 0000000000..b37f019305 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-jsoncoverage-files-1.json @@ -0,0 +1,319 @@ +[ + { + "filename": "src/main/java/com/example/Main.kt", + "lineHits": { + "28": 0, + "31": 0, + "36": 0, + "37": 0, + "38": 0, + "39": 0, + "40": 0, + "41": 0, + "42": 0, + "43": 0, + "44": 0, + "45": 0, + "46": 0, + "47": 0, + "48": 0, + "49": 0, + "50": 0, + "52": 0, + "53": 0, + "56": 0, + "57": 0, + "58": 0, + "59": 0, + "60": 0, + "62": 0, + "65": 0, + "66": 0, + "67": 0, + "69": 0, + "70": 0, + "71": 0, + "72": 0, + "74": 0, + "76": 0, + "77": 0, + "78": 0, + "79": 0, + "81": 0, + "82": 0, + "83": 0, + "84": 0, + "85": 0, + "86": 0, + "87": 0, + "88": 0, + "89": 0, + "92": 0, + "93": 0 + }, + "branchHits": {} + }, + { + "filename": "src/main/java/com/example/Parser.kt", + "lineHits": { + "5": 9, + "10": 7, + "12": 8, + "13": 2, + "14": 3, + "16": 28, + "17": 1, + "19": 4, + "20": 16, + "21": 18, + "22": 12, + "23": 5, + "24": 14, + "25": 22, + "26": 9, + "28": 7, + "29": 1, + "32": 2, + "34": 13, + "36": 0, + "37": 0, + "38": 0, + "39": 0, + "40": 0, + "43": 7, + "44": 0, + "46": 2, + "49": 7, + "50": 1, + "53": 5, + "54": 3, + "55": 18, + "56": 3, + "57": 1, + "58": 3, + "59": 3, + "60": 5, + "62": 1, + "64": 6, + "65": 2, + "69": 5, + "70": 14, + "71": 8, + "72": 20, + "73": 16, + "74": 22, + "75": 3, + "76": 2, + "77": 19, + "78": 1, + "79": 13, + "80": 5, + "81": 4, + "83": 4, + "84": 12, + "85": 10, + "86": 7, + "87": 9, + "94": 10, + "95": 5, + "96": 4, + "98": 9, + "99": 12, + "100": 10, + "101": 7, + "102": 4, + "103": 9, + "104": 10, + "105": 5, + "106": 10, + "107": 4, + "108": 3, + "109": 2, + "110": 5, + "111": 2, + "113": 1, + "114": 7, + "116": 3, + "117": 12, + "118": 26, + "119": 10, + "120": 7, + "122": 3, + "123": 9, + "124": 14, + "125": 2, + "126": 3 + }, + "branchHits": { + "12": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "16": { + "covered": 2, + "missed": 2, + "available": 4 + }, + "24": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "25": { + "covered": 6, + "missed": 2, + "available": 8 + }, + "38": { + "covered": 0, + "missed": 4, + "available": 4 + }, + "43": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "44": { + "covered": 0, + "missed": 2, + "available": 2 + }, + "55": { + "covered": 4, + "missed": 0, + "available": 4 + }, + "70": { + "covered": 1, + "missed": 3, + "available": 4 + }, + "73": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "75": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "77": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "79": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "85": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "94": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "100": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "104": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "108": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "119": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "124": { + "covered": 2, + "missed": 0, + "available": 2 + } + } + }, + { + "filename": "src/main/java/com/example/models/Thing.kt", + "lineHits": { + "9": 8, + "10": 3, + "11": 0, + "14": 8, + "15": 3, + "16": 1, + "19": 8, + "20": 3, + "21": 0, + "24": 3, + "25": 3, + "26": 1, + "29": 11, + "30": 3, + "31": 3, + "32": 3, + "35": 12, + "36": 12, + "37": 1, + "40": 5, + "41": 3, + "44": 3, + "45": 1, + "48": 12, + "49": 3, + "50": 3, + "51": 0 + }, + "branchHits": { + "14": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "15": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "24": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "25": { + "covered": 1, + "missed": 1, + "available": 2 + }, + "35": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "36": { + "covered": 2, + "missed": 0, + "available": 2 + }, + "44": { + "covered": 1, + "missed": 1, + "available": 2 + } + } + } +] diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-sourcefiles-1.txt b/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-sourcefiles-1.txt new file mode 100644 index 0000000000..84e69b0b4b --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-sourcefiles-1.txt @@ -0,0 +1,7 @@ +pom.xml +src/main/java/com/example/Parser.kt +src/main/java/com/example/Main.kt +src/main/java/com/example/models/Thing.kt +src/main/resources/app.conf +src/test/java/com/example/ParserTest.kt +src/test/resources/logback-test.xml diff --git a/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-testdata-1.xml b/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-testdata-1.xml new file mode 100644 index 0000000000..d07dd81b50 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/__fixtures__/jacoco-testdata-1.xml @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/code-coverage-backend/src/service/converter/Converter.ts b/plugins/code-coverage-backend/src/service/converter/Converter.ts new file mode 100644 index 0000000000..43d9855e61 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/Converter.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FileEntry } from '../types'; + +export interface Converter { + convert(xml: unknown, scmFiles: Array): Array; +} diff --git a/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts b/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts new file mode 100644 index 0000000000..0e67d35d7a --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { parseString } from 'xml2js'; +import fs from 'fs'; +import path from 'path'; +import { Cobertura } from './cobertura'; +import { CoberturaXML } from './types'; +import { getVoidLogger } from '@backstage/backend-common'; + +/* eslint-disable no-restricted-syntax */ + +describe('convert cobertura', () => { + const converter = new Cobertura(getVoidLogger()); + [1, 2, 3, 4, 5].forEach(idx => { + let fixture: CoberturaXML; + parseString( + fs.readFileSync( + path.resolve( + __dirname, + '..', + '__fixtures__', + `cobertura-testdata-${idx}.xml`, + ), + ), + (_e, r) => { + fixture = r; + }, + ); + const expected = JSON.parse( + fs + .readFileSync( + path.resolve( + __dirname, + '..', + '__fixtures__', + `cobertura-jsoncoverage-files-${idx}.json`, + ), + ) + .toString(), + ); + const scmFiles = fs + .readFileSync( + path.resolve( + __dirname, + '..', + '__fixtures__', + `cobertura-sourcefiles-${idx}.txt`, + ), + ) + .toString() + .split('\n'); + + it(`${idx} converts a cobertura report`, () => { + const files = converter.convert(fixture, scmFiles); + + expect(files).toEqual(expected); + }); + }); +}); diff --git a/plugins/code-coverage-backend/src/service/converter/cobertura.ts b/plugins/code-coverage-backend/src/service/converter/cobertura.ts new file mode 100644 index 0000000000..fcaea839ee --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/cobertura.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BranchHit, FileEntry } from '../types'; +import { CoberturaXML, InnerClass, LineHit } from './types'; +import { Logger } from 'winston'; +import { Converter } from './Converter'; + +export class Cobertura implements Converter { + constructor(readonly logger: Logger) { + this.logger = logger; + } + + /** + * convert cobertura into shared json coverage format + * + * @param xml cobertura xml object + * @param scmFiles list of files that are commited to SCM + */ + convert(xml: CoberturaXML, scmFiles: string[]): FileEntry[] { + const ppc = xml.coverage.packages + ?.flatMap(p => p.package) + .filter(Boolean) + .flatMap(p => p.classes); + const pc = xml.coverage.package?.filter(Boolean).flatMap(p => p.classes); + + const classes = [ppc, pc] + .flat() + .filter(Boolean) + .flatMap(c => c.class) + .filter(Boolean); + const jscov: Array = []; + + classes.forEach(c => { + const packageAndFilename = c.$.filename; + const lines = this.extractLines(c); + const lineHits: Record = {}; + const branchHits: Record = {}; + + lines.forEach(l => { + if (!lineHits[l.number]) { + lineHits[l.number] = 0; + } + lineHits[l.number] += l.hits; + if (l.branch && l['condition-coverage']) { + const bh = this.parseBranch(l['condition-coverage']); + if (bh) { + branchHits[l.number] = bh; + } + } + }); + + const currentFile = scmFiles + .map(f => f.trimEnd()) + .find(f => f.endsWith(packageAndFilename)); + this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`); + if ( + scmFiles.length === 0 || + (Object.keys(lineHits).length > 0 && currentFile) + ) { + jscov.push({ + filename: currentFile || packageAndFilename, + branchHits: branchHits, + lineHits: lineHits, + }); + } + }); + + return jscov; + } + + /** + * Parses branch coverage information from condition-coverage + * + * @param condition condition-coverage value from line coverage + */ + private parseBranch(condition: string): BranchHit | null { + const pattern = /[0-9\.]+\%\s\(([0-9]+)\/([0-9]+)\)/; + const match = condition.match(pattern); + if (!match) { + return null; + } + const covered = parseInt(match[1], 10); + const available = parseInt(match[2], 10); + return { + covered: covered, + missed: available - covered, + available: available, + }; + } + + /** + * Extract line hits from a class coverage entry + * + * @param clz class coverage information + */ + private extractLines(clz: InnerClass): Array { + const classLines = clz.lines.flatMap(l => l.line); + const methodLines = clz.methods + ?.flatMap(m => m.method) + .filter(Boolean) + .flatMap(m => m.lines) + .filter(Boolean) + .flatMap(l => l.line); + const lines = [classLines, methodLines].flat().filter(Boolean); + const lineHits = lines.map(l => { + return { + number: parseInt((l.$.number as unknown) as string, 10), + hits: parseInt((l.$.hits as unknown) as string, 10), + 'condition-coverage': l.$['condition-coverage'], + branch: l.$.branch, + }; + }); + return lineHits; + } +} diff --git a/plugins/code-coverage-backend/src/service/converter/index.ts b/plugins/code-coverage-backend/src/service/converter/index.ts new file mode 100644 index 0000000000..8ea7a41f65 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './Converter'; +export { Cobertura } from './cobertura'; +export { Jacoco } from './jacoco'; diff --git a/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts b/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts new file mode 100644 index 0000000000..0507cf3a98 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { parseString } from 'xml2js'; +import fs from 'fs'; +import path from 'path'; +import { Jacoco } from './jacoco'; +import { JacocoXML } from './types'; +import { getVoidLogger } from '@backstage/backend-common'; + +/* eslint-disable no-restricted-syntax */ + +describe('convert jacoco', () => { + const converter = new Jacoco(getVoidLogger()); + let fixture: JacocoXML; + parseString( + fs.readFileSync( + path.resolve(`${__dirname}/../__fixtures__/jacoco-testdata-1.xml`), + ), + (_e, r) => { + fixture = r; + }, + ); + const expected = JSON.parse( + fs + .readFileSync( + path.resolve( + `${__dirname}/../__fixtures__/jacoco-jsoncoverage-files-1.json`, + ), + ) + .toString(), + ); + const scmFiles = fs + .readFileSync( + path.resolve(`${__dirname}/../__fixtures__/jacoco-sourcefiles-1.txt`), + ) + .toString() + .split('\n'); + + it('converts a jacoco report', () => { + const files = converter.convert(fixture, scmFiles); + + expect(files.sort()).toEqual(expected.sort()); + }); +}); diff --git a/plugins/code-coverage-backend/src/service/converter/jacoco.ts b/plugins/code-coverage-backend/src/service/converter/jacoco.ts new file mode 100644 index 0000000000..e4a41f8bc4 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/jacoco.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BranchHit, FileEntry } from '../types'; +import { JacocoSourceFile, JacocoXML } from './types'; +import { Logger } from 'winston'; +import { Converter } from './Converter'; + +type ParsedLine = { + number: number; + missed_instructions: number; + covered_instructions: number; + missed_branches: number; + covered_branches: number; +}; + +export class Jacoco implements Converter { + constructor(readonly logger: Logger) { + this.logger = logger; + } + + /** + * Converts jacoco into shared json coverage format + * + * @param xml jacoco xml object + * @param scmFiles list of files that are committed to SCM + */ + convert(xml: JacocoXML, scmFiles: Array): Array { + const jscov: Array = []; + + xml.report.package.forEach(r => { + const packageName = r.$.name; + r.sourcefile.forEach(sf => { + const fileName = sf.$.name; + const lines = this.extractLines(sf); + const lineHits: Record = {}; + const branchHits: Record = {}; + lines.forEach(l => { + if (!lineHits[l.number]) { + lineHits[l.number] = 0; + } + lineHits[l.number] += l.covered_instructions; + const ab = l.covered_branches + l.missed_branches; + if (ab > 0) { + branchHits[l.number] = { + covered: l.covered_branches, + missed: l.missed_branches, + available: ab, + }; + } + }); + + const packageAndFilename = `${packageName}/${fileName}`; + const currentFile = scmFiles + .map(f => f.trimEnd()) + .find(f => f.endsWith(packageAndFilename)); + this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`); + if (Object.keys(lineHits).length > 0 && currentFile) { + jscov.push({ + filename: currentFile, + branchHits: branchHits, + lineHits: lineHits, + }); + } + }); + }); + + return jscov; + } + + private extractLines(sourcefile: JacocoSourceFile): ParsedLine[] { + const parsed: ParsedLine[] = []; + + sourcefile.line.forEach(l => { + parsed.push({ + number: parseInt(l.$.nr, 10), + missed_instructions: parseInt(l.$.mi, 10), + covered_instructions: parseInt(l.$.ci, 10), + missed_branches: parseInt(l.$.mb, 10), + covered_branches: parseInt(l.$.cb, 10), + }); + }); + + return parsed; + } +} diff --git a/plugins/code-coverage-backend/src/service/converter/types.ts b/plugins/code-coverage-backend/src/service/converter/types.ts new file mode 100644 index 0000000000..35e70faae4 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/converter/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// *** Cobertura *** +export type CoberturaXML = { + coverage: Coverage; +}; + +export type Coverage = { + packages: Array; + package: Array; +}; + +export type Package = { + package: Array; +}; + +export type InnerPackage = { + classes: Array; +}; +export type Class = { + class: Array; +}; +export type InnerClass = { + $: { + filename: string; + }; + lines: Array; + methods: Array; +}; +export type Method = { + method: Array; +}; +export type InnerMethod = { + lines: Array; +}; +export type Line = { + line: Array; +}; + +export type InnerLine = { + $: LineHit; +}; + +export type LineHit = { + branch?: boolean; + 'condition-coverage'?: string; + number: number; + hits: number; +}; + +// *** Jacoco *** +export type JacocoXML = { + report: JacocoReport; +}; +export type JacocoReport = { + package: JacocoPackage[]; +}; +export type JacocoPackage = { + $: { + name: string; + }; + sourcefile: JacocoSourceFile[]; +}; +export type JacocoSourceFile = { + $: { + name: string; + }; + line: JacocoLine[]; +}; +export type JacocoLine = { + $: { + nr: string; + mi: string; + ci: string; + mb: string; + cb: string; + }; +}; diff --git a/plugins/code-coverage-backend/src/service/router.test.ts b/plugins/code-coverage-backend/src/service/router.test.ts new file mode 100644 index 0000000000..e18cd75c2f --- /dev/null +++ b/plugins/code-coverage-backend/src/service/router.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import request from 'supertest'; +import { + getVoidLogger, + PluginDatabaseManager, + PluginEndpointDiscovery, + SingleConnectionDatabaseManager, + UrlReaders, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { createRouter } from './router'; + +function createDatabase(): PluginDatabaseManager { + return SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('code-coverage'); +} + +const testDiscovery: jest.Mocked = { + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/api/code-coverage'), + getExternalBaseUrl: jest.fn(), +}; +const mockUrlReader = UrlReaders.default({ + logger: getVoidLogger(), + config: new ConfigReader({}), +}); + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + config: new ConfigReader({}), + database: createDatabase(), + discovery: testDiscovery, + urlReader: mockUrlReader, + logger: getVoidLogger(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/code-coverage-backend/src/service/router.ts b/plugins/code-coverage-backend/src/service/router.ts new file mode 100644 index 0000000000..78aeab313c --- /dev/null +++ b/plugins/code-coverage-backend/src/service/router.ts @@ -0,0 +1,234 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import xmlparser from 'express-xml-bodyparser'; +import { CatalogClient } from '@backstage/catalog-client'; +import { + errorHandler, + PluginDatabaseManager, + PluginEndpointDiscovery, + UrlReader, +} from '@backstage/backend-common'; +import { InputError, NotFoundError } from '@backstage/errors'; +import { Config } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { CodeCoverageDatabase } from './CodeCoverageDatabase'; +import { aggregateCoverage, CoverageUtils } from './CoverageUtils'; +import { Cobertura } from './converter/cobertura'; +import { Jacoco } from './converter/jacoco'; +import { Converter } from './converter'; +import { + EntityRef, + getEntitySourceLocation, + parseEntityName, +} from '@backstage/catalog-model'; + +export interface RouterOptions { + config: Config; + discovery: PluginEndpointDiscovery; + database: PluginDatabaseManager; + urlReader: UrlReader; + logger: Logger; +} + +export interface CodeCoverageApi { + name: string; +} + +export const makeRouter = async ( + options: RouterOptions, +): Promise => { + const { config, logger, discovery, database, urlReader } = options; + + const codeCoverageDatabase = await CodeCoverageDatabase.create( + await database.getClient(), + ); + const codecovUrl = await discovery.getExternalBaseUrl('code-coverage'); + const catalogApi = new CatalogClient({ discoveryApi: discovery }); + const scm = ScmIntegrations.fromConfig(config); + + const router = Router(); + router.use(xmlparser()); + router.use(express.json()); + + const utils = new CoverageUtils(scm, urlReader); + + router.get('/health', async (_req, res) => { + res.status(200).json({ status: 'ok' }); + }); + + /** + * /report?entity=component:default/mycomponent + */ + router.get('/report', async (req, res) => { + const { entity } = req.query; + const entityName = parseEntityName(entity as EntityRef); + const entityLookup = await catalogApi.getEntityByName(entityName); + if (!entityLookup) { + throw new NotFoundError(`No entity found matching ${entity}`); + } + const stored = await codeCoverageDatabase.getCodeCoverage(entity as string); + + const aggregate = aggregateCoverage(stored); + + res.status(200).json({ + ...stored, + aggregate: { + line: aggregate.line, + branch: aggregate.branch, + }, + }); + }); + + /** + * /history?entity=component:default/mycomponent + */ + router.get('/history', async (req, res) => { + const { entity } = req.query; + const entityName = parseEntityName(entity as EntityRef); + const entityLookup = await catalogApi.getEntityByName(entityName); + if (!entityLookup) { + throw new NotFoundError(`No entity found matching ${entity}`); + } + const { limit } = req.query; + const history = await codeCoverageDatabase.getHistory( + entity as string, + parseInt(limit?.toString() || '10', 10), + ); + + res.status(200).json(history); + }); + + /** + * /file-content?entity=component:default/mycomponent&path=src/some-file.go + */ + router.get('/file-content', async (req, res) => { + const { entity, path } = req.query; + const entityName = parseEntityName(entity as EntityRef); + const entityLookup = await catalogApi.getEntityByName(entityName); + if (!entityLookup) { + throw new NotFoundError(`No entity found matching ${entity}`); + } + + if (!path) { + throw new InputError('Need path query parameter'); + } + + const sourceLocation = getEntitySourceLocation(entityLookup); + + if (!sourceLocation) { + throw new InputError( + `No "backstage.io/source-location" annotation on entity ${entity}`, + ); + } + + const vcs = scm.byUrl(sourceLocation.target); + if (!vcs) { + throw new InputError(`Unable to determine SCM from ${sourceLocation}`); + } + + const scmTree = await urlReader.readTree(sourceLocation.target); + const scmFile = (await scmTree.files()).find(f => f.path === path); + if (!scmFile) { + res.status(404).json({ + message: "Couldn't find file in SCM", + file: path, + scm: vcs.title, + }); + return; + } + const content = await scmFile?.content(); + if (!content) { + res.status(400).json({ + message: "Couldn't process content of file in SCM", + file: path, + scm: vcs.title, + }); + return; + } + + const data = content.toString(); + res.status(200).contentType('text/plain').send(data); + }); + + /** + * /report?entity=component:default/mycomponent&coverageType=cobertura + */ + router.post('/report', async (req, res) => { + const { entity, coverageType } = req.query; + const entityName = parseEntityName(entity as EntityRef); + const entityLookup = await catalogApi.getEntityByName(entityName); + if (!entityLookup) { + throw new NotFoundError(`No entity found matching ${entity}`); + } + + let converter: Converter; + if (!coverageType) { + throw new InputError('Need coverageType query parameter'); + } else if (coverageType === 'jacoco') { + converter = new Jacoco(logger); + } else if (coverageType === 'cobertura') { + converter = new Cobertura(logger); + } else { + throw new InputError(`Unsupported coverage type '${coverageType}`); + } + + const { + sourceLocation, + vcs, + scmFiles, + body, + } = await utils.processCoveragePayload(entityLookup, req); + + const files = converter.convert(body, scmFiles); + if (!files || files.length === 0) { + throw new InputError(`Unable to parse body as ${coverageType}`); + } + + const coverage = await utils.buildCoverage( + entityLookup, + sourceLocation, + vcs, + files, + ); + await codeCoverageDatabase.insertCodeCoverage(coverage); + + res.status(201).json({ + links: [ + { + rel: 'coverage', + href: `${codecovUrl}/report?entity=${entity}`, + }, + ], + }); + }); + + router.use(errorHandler()); + return router; +}; + +export async function createRouter( + options: RouterOptions, +): Promise { + const logger = options.logger; + + logger.info('Initializing Code Coverage backend'); + + return makeRouter(options); +} diff --git a/plugins/code-coverage-backend/src/service/standaloneServer.ts b/plugins/code-coverage-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..2fb9627936 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/standaloneServer.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createServiceBuilder, + loadBackendConfig, + SingleHostDiscovery, + UrlReaders, + useHotMemoize, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import knexFactory from 'knex'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'code-coverage-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + + const db = useHotMemoize(module, () => { + const knex = knexFactory({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + }); + + logger.debug('Starting application server...'); + const router = await createRouter({ + database: { getClient: async () => db }, + config, + discovery: SingleHostDiscovery.fromConfig(config), + urlReader: UrlReaders.default({ logger, config }), + logger, + }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/code-coverage', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/code-coverage-backend/src/service/types.ts b/plugins/code-coverage-backend/src/service/types.ts new file mode 100644 index 0000000000..34fcf57968 --- /dev/null +++ b/plugins/code-coverage-backend/src/service/types.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EntityName } from '@backstage/catalog-model'; + +export type JsonCodeCoverage = { + metadata: CoverageMetadata; + entity: EntityName; + files: Array; +}; + +export type JsonCoverageHistory = { + entity: EntityName; + history: Array; +}; + +export type CoverageHistory = { + line: { + available: number; + covered: number; + }; + branch: BranchHit; +}; + +export type CoverageMetadata = { + vcs: { + type: string; + location: string; + }; + generationTime: number; +}; + +export type BranchHit = { + covered: number; + missed: number; + available: number; +}; + +export type FileEntry = { + filename: string; + lineHits: Record; + branchHits: Record; +}; + +export type AggregateCoverage = { + timestamp: number; + line: { + available: number; + covered: number; + missed: number; + percentage: number; + }; + branch: { + available: number; + covered: number; + missed: number; + percentage: number; + }; +}; diff --git a/plugins/code-coverage-backend/src/setupTests.ts b/plugins/code-coverage-backend/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/plugins/code-coverage-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/plugins/code-coverage/.eslintrc.js b/plugins/code-coverage/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/code-coverage/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/code-coverage/CHANGELOG.md b/plugins/code-coverage/CHANGELOG.md new file mode 100644 index 0000000000..4a55b7f0f5 --- /dev/null +++ b/plugins/code-coverage/CHANGELOG.md @@ -0,0 +1,17 @@ +# @backstage/plugin-code-coverage + +## 0.1.2 + +### Patch Changes + +- 55b2fc0c0: Update tests to function in windows +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 diff --git a/plugins/code-coverage/README.md b/plugins/code-coverage/README.md new file mode 100644 index 0000000000..8903668507 --- /dev/null +++ b/plugins/code-coverage/README.md @@ -0,0 +1,23 @@ +# code-coverage + +This is the frontend part of the code-coverage plugin. It displays code coverage summaries for your entities. + +## Configuring your entity + +In order to use this plugin, you must set the `backstage.io/code-coverage` annotation on entities for which coverage ingestion has been enabled. + +```yaml +metadata: + annotations: + backstage.io/code-coverage: enabled +``` + +There's a feature to only include files that are in VCS in the coverage report, this is helpful to not count generated files for example. To enable this set the `backstage.io/code-coverage` annotation to `scm-only`. + +```yaml +metadata: + annotations: + backstage.io/code-coverage: scm-only +``` + +Note: It may be required to set the [`backstage.io/source-location` annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location), however this should generally not be needed. diff --git a/plugins/code-coverage/dev/index.tsx b/plugins/code-coverage/dev/index.tsx new file mode 100644 index 0000000000..69ff4bcda2 --- /dev/null +++ b/plugins/code-coverage/dev/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { codeCoveragePlugin, EntityCodeCoverageContent } from '../src/plugin'; + +createDevApp() + .registerPlugin(codeCoveragePlugin) + .addPage({ + element: , + title: 'Root Page', + }) + .render(); diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json new file mode 100644 index 0000000000..f5c36811cc --- /dev/null +++ b/plugins/code-coverage/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-code-coverage", + "version": "0.1.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.7.7", + "@backstage/config": "^0.1.4", + "@backstage/core": "^0.7.7", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/theme": "^0.2.6", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/styles": "^4.11.0", + "@material-ui/lab": "4.0.0-alpha.45", + "highlight.js": "^10.6.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "recharts": "^1.8.5" + }, + "devDependencies": { + "@backstage/cli": "^0.6.9", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.10", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/highlightjs": "^10.1.0", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/recharts": "^1.8.15", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/code-coverage/src/api.ts b/plugins/code-coverage/src/api.ts new file mode 100644 index 0000000000..6331f8020f --- /dev/null +++ b/plugins/code-coverage/src/api.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityName, stringifyEntityRef } from '@backstage/catalog-model'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { ResponseError } from '@backstage/errors'; +import { JsonCodeCoverage, JsonCoverageHistory } from './types'; + +export type CodeCoverageApi = { + discovery: DiscoveryApi; + getCoverageForEntity: (entity: EntityName) => Promise; + getFileContentFromEntity: ( + entity: EntityName, + filePath: string, + ) => Promise; + getCoverageHistoryForEntity: ( + entity: EntityName, + limit?: number, + ) => Promise; +}; + +export const codeCoverageApiRef = createApiRef({ + id: 'plugin.code-coverage.service', + description: 'Used by the code coverage plugin to make requests', +}); + +export class CodeCoverageRestApi implements CodeCoverageApi { + url: string = ''; + + constructor(public discovery: DiscoveryApi) {} + + private async fetch( + path: string, + init?: RequestInit, + ): Promise { + if (!this.url) { + this.url = await this.discovery.getBaseUrl('code-coverage'); + } + const resp = await fetch(`${this.url}${path}`, init); + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + if (resp.headers.get('content-type')?.includes('application/json')) { + return await resp.json(); + } + return await resp.text(); + } + + async getCoverageForEntity( + entityName: EntityName, + ): Promise { + const entity = encodeURIComponent(stringifyEntityRef(entityName)); + return (await this.fetch( + `/report?entity=${entity}`, + )) as JsonCodeCoverage; + } + + async getFileContentFromEntity( + entityName: EntityName, + filePath: string, + ): Promise { + const entity = encodeURIComponent(stringifyEntityRef(entityName)); + return await this.fetch( + `/file-content?entity=${entity}&path=${encodeURI(filePath)}`, + ); + } + + async getCoverageHistoryForEntity( + entityName: EntityName, + limit?: number, + ): Promise { + const entity = encodeURIComponent(stringifyEntityRef(entityName)); + const hasValidLimit = limit && limit > 0; + return (await this.fetch( + `/history?entity=${entity}${ + hasValidLimit ? `&limit=${encodeURIComponent(String(limit))}` : '' + }`, + )) as JsonCoverageHistory; + } +} diff --git a/plugins/code-coverage/src/components/CodeCoveragePage/CodeCoveragePage.tsx b/plugins/code-coverage/src/components/CodeCoveragePage/CodeCoveragePage.tsx new file mode 100644 index 0000000000..47811b93cd --- /dev/null +++ b/plugins/code-coverage/src/components/CodeCoveragePage/CodeCoveragePage.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Content, ContentHeader, Page } from '@backstage/core'; +import { CoverageHistoryChart } from '../CoverageHistoryChart'; +import { FileExplorer } from '../FileExplorer'; + +export const CodeCoveragePage = () => { + return ( + + + + + + + + ); +}; diff --git a/plugins/code-coverage/src/components/CodeCoveragePage/index.ts b/plugins/code-coverage/src/components/CodeCoveragePage/index.ts new file mode 100644 index 0000000000..c5479c0098 --- /dev/null +++ b/plugins/code-coverage/src/components/CodeCoveragePage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { CodeCoveragePage } from './CodeCoveragePage'; diff --git a/plugins/code-coverage/src/components/CoverageHistoryChart/CoverageHistoryChart.tsx b/plugins/code-coverage/src/components/CoverageHistoryChart/CoverageHistoryChart.tsx new file mode 100644 index 0000000000..7bb4648f6f --- /dev/null +++ b/plugins/code-coverage/src/components/CoverageHistoryChart/CoverageHistoryChart.tsx @@ -0,0 +1,166 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Progress, ResponseErrorPanel, useApi } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { BackstageTheme } from '@backstage/theme'; +import { + Box, + Card, + CardContent, + CardHeader, + makeStyles, + Typography, +} from '@material-ui/core'; +import TrendingDownIcon from '@material-ui/icons/TrendingDown'; +import TrendingFlatIcon from '@material-ui/icons/TrendingFlat'; +import TrendingUpIcon from '@material-ui/icons/TrendingUp'; +import { Alert } from '@material-ui/lab'; +import { ClassNameMap } from '@material-ui/styles'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { codeCoverageApiRef } from '../../api'; + +type Coverage = 'line' | 'branch'; + +const useStyles = makeStyles(theme => ({ + trendDown: { + color: theme.palette.status.warning, + }, + trendUp: { + color: theme.palette.status.ok, + }, +})); + +const getTrendIcon = (trend: number, classes: ClassNameMap) => { + switch (true) { + case trend > 0: + return ; + case trend < 0: + return ; + case trend === 0: + default: + return ; + } +}; + +export const CoverageHistoryChart = () => { + const { entity } = useEntity(); + const codeCoverageApi = useApi(codeCoverageApiRef); + const { + loading: loadingHistory, + error: errorHistory, + value: valueHistory, + } = useAsync( + async () => + await codeCoverageApi.getCoverageHistoryForEntity({ + kind: entity.kind, + namespace: entity.metadata.namespace || 'default', + name: entity.metadata.name, + }), + ); + const classes = useStyles(); + + if (loadingHistory) { + return ; + } + if (errorHistory) { + return ; + } else if (!valueHistory) { + return No history found.; + } + + if (!valueHistory.history.length) { + return ( + + + No coverage history found + + ); + } + + const oldestCoverage = valueHistory.history[0]; + const [latestCoverage] = valueHistory.history.slice(-1); + + const getTrendForCoverage = (type: Coverage) => { + if (!oldestCoverage[type].percentage) { + return 0; + } + return ( + ((latestCoverage[type].percentage - oldestCoverage[type].percentage) / + oldestCoverage[type].percentage) * + 100 + ); + }; + + const lineTrend = getTrendForCoverage('line'); + const branchTrend = getTrendForCoverage('branch'); + + return ( + + + + + + {getTrendIcon(lineTrend, classes)} + + Current line: {latestCoverage.line.percentage}%
( + {Math.floor(lineTrend)}% change over {valueHistory.history.length}{' '} + builds) +
+
+ + {getTrendIcon(branchTrend, classes)} + + Current branch: {latestCoverage.branch.percentage}%
( + {Math.floor(branchTrend)}% change over{' '} + {valueHistory.history.length} builds) +
+
+
+ + + + + + + + + + + + +
+
+ ); +}; diff --git a/plugins/code-coverage/src/components/CoverageHistoryChart/index.ts b/plugins/code-coverage/src/components/CoverageHistoryChart/index.ts new file mode 100644 index 0000000000..b9dd72e34b --- /dev/null +++ b/plugins/code-coverage/src/components/CoverageHistoryChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { CoverageHistoryChart } from './CoverageHistoryChart'; diff --git a/plugins/code-coverage/src/components/FileExplorer/CodeRow.tsx b/plugins/code-coverage/src/components/FileExplorer/CodeRow.tsx new file mode 100644 index 0000000000..bd982b0f6b --- /dev/null +++ b/plugins/code-coverage/src/components/FileExplorer/CodeRow.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + lineNumberCell: { + color: `${theme.palette.grey[500]}`, + fontSize: '90%', + borderRight: `1px solid ${theme.palette.grey[500]}`, + paddingRight: theme.spacing(1), + textAlign: 'right', + }, + hitCountCell: { + width: '50px', + borderRight: `1px solid ${theme.palette.grey[500]}`, + textAlign: 'center', + color: 'white', // need to enforce this color since it needs to stand out against colored background + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + countRoundedRectangle: { + borderRadius: '45px', + fontSize: '90%', + padding: '1px 3px 1px 3px', + width: '50px', + }, + hitCountRoundedRectangle: { + backgroundColor: `${theme.palette.success.main}`, + }, + notHitCountRoundedRectangle: { + backgroundColor: `${theme.palette.error.main}`, + }, + codeLine: { + paddingLeft: `${theme.spacing(1)}`, + whiteSpace: 'pre', + fontSize: '90%', + }, + hitCodeLine: { + backgroundColor: `${theme.palette.success.main}`, + }, + notHitCodeLine: { + backgroundColor: `${theme.palette.error.main}`, + }, +})); + +type CodeRowProps = { + lineNumber: number; + lineContent: string; + lineHits?: number | null; +}; + +export const CodeRow = ({ + lineNumber, + lineContent, + lineHits = null, +}: CodeRowProps) => { + const classes = useStyles(); + const hitCountRoundedRectangleClass = [classes.countRoundedRectangle]; + const lineContentClass = [classes.codeLine]; + + let hitRoundedRectangle = null; + if (lineHits !== null) { + if (lineHits > 0) { + hitCountRoundedRectangleClass.push(classes.hitCountRoundedRectangle); + lineContentClass.push(classes.hitCodeLine); + } else { + hitCountRoundedRectangleClass.push(classes.notHitCountRoundedRectangle); + lineContentClass.push(classes.notHitCodeLine); + } + hitRoundedRectangle = ( +
{lineHits}
+ ); + } + + return ( + + {lineNumber} + {hitRoundedRectangle} + + + ); +}; diff --git a/plugins/code-coverage/src/components/FileExplorer/FileContent.tsx b/plugins/code-coverage/src/components/FileExplorer/FileContent.tsx new file mode 100644 index 0000000000..b43874123d --- /dev/null +++ b/plugins/code-coverage/src/components/FileExplorer/FileContent.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Progress, ResponseErrorPanel, useApi } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { makeStyles, Paper } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { codeCoverageApiRef } from '../../api'; +import { FileEntry } from '../../types'; +import { CodeRow } from './CodeRow'; +import { highlightLines } from './Highlighter'; + +type Props = { + filename: string; + coverage: FileEntry; +}; + +const useStyles = makeStyles(theme => ({ + paper: { + margin: 'auto', + top: '2em', + width: '80%', + border: '2px solid #000', + boxShadow: theme.shadows[5], + padding: theme.spacing(2, 4, 3), + overflow: 'scroll', + }, + coverageFileViewTable: { + borderSpacing: '0px', + width: '80%', + marginTop: theme.spacing(2), + }, +})); + +type FormattedLinesProps = { + highlightedLines: string[]; + lineHits: Record; +}; +const FormattedLines = ({ + highlightedLines, + lineHits, +}: FormattedLinesProps) => { + return ( + <> + {highlightedLines.map((lineContent, idx) => { + const line = idx + 1; + return ( + + ); + })} + + ); +}; + +export const FileContent = ({ filename, coverage }: Props) => { + const { entity } = useEntity(); + const codeCoverageApi = useApi(codeCoverageApiRef); + const { loading, error, value } = useAsync( + async () => + await codeCoverageApi.getFileContentFromEntity( + { + kind: entity.kind, + namespace: entity.metadata.namespace || 'default', + name: entity.metadata.name, + }, + filename, + ), + [entity], + ); + + const classes = useStyles(); + + if (loading) { + return ; + } + if (error) { + return ; + } + if (!value) { + return ( + + Unable to retrieve file content for {filename} + + ); + } + + const [language] = filename.split('.').slice(-1); + const highlightedLines = highlightLines(language, value.split('\n')); + + // List of formatted nodes containing highlighted code + // lineHits array where lineHits[i] is number of hits for line i + 1 + const lineHits = Object.entries(coverage.lineHits).reduce( + (acc: Record, next: [string, number]) => { + acc[next[0]] = next[1]; + return acc; + }, + {}, + ); + + return ( + + + + + +
+
+ ); +}; diff --git a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx new file mode 100644 index 0000000000..b49b78c5dc --- /dev/null +++ b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx @@ -0,0 +1,285 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Progress, + ResponseErrorPanel, + Table, + TableColumn, + useApi, +} from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + Box, + Card, + CardContent, + CardHeader, + Modal, + Tooltip, +} from '@material-ui/core'; +import DescriptionIcon from '@material-ui/icons/Description'; +import { Alert } from '@material-ui/lab'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; +import { codeCoverageApiRef } from '../../api'; +import { FileEntry } from '../../types'; +import { FileContent } from './FileContent'; + +type FileStructureObject = Record; + +type CoverageTableRow = { + filename?: string; + files: CoverageTableRow[]; + coverage: number; + missing: number; + tracked: number; + path: string; + tableData?: { id: number }; +}; + +const buildFileStructure = (row: CoverageTableRow) => { + const dataGroupedByPath: FileStructureObject = row.files.reduce( + (acc: FileStructureObject, cur: CoverageTableRow) => { + let path = cur.filename; + if (row.path) { + path = path?.split(`${row.path}/`)[1]; + } + const pathArray = path?.split('/'); + + if (!pathArray) { + return acc; + } + if (!acc.hasOwnProperty(pathArray[0])) { + acc[pathArray[0]] = []; + } + acc[pathArray[0]].push(cur); + return acc; + }, + {}, + ); + + row.files = Object.keys(dataGroupedByPath).map(pathGroup => { + return buildFileStructure({ + path: pathGroup, + files: dataGroupedByPath.hasOwnProperty('files') + ? dataGroupedByPath.files + : dataGroupedByPath[pathGroup], + coverage: + dataGroupedByPath[pathGroup].reduce( + (acc: number, cur: CoverageTableRow) => acc + cur.coverage, + 0, + ) / dataGroupedByPath[pathGroup].length, + missing: dataGroupedByPath[pathGroup].reduce( + (acc: number, cur: CoverageTableRow) => acc + cur.missing, + 0, + ), + tracked: dataGroupedByPath[pathGroup].reduce( + (acc: number, cur: CoverageTableRow) => acc + cur.tracked, + 0, + ), + }); + }); + return row; +}; + +const formatInitialData = (value: any) => { + return buildFileStructure({ + path: '', + coverage: value.aggregate.line.percentage, + missing: value.aggregate.line.missed, + tracked: value.aggregate.line.available, + files: value.files.map((fc: FileEntry) => { + return { + path: '', + filename: fc.filename, + coverage: Math.floor( + (Object.values(fc.lineHits).filter((hits: number) => hits > 0) + .length / + Object.values(fc.lineHits).length) * + 100, + ), + missing: Object.values(fc.lineHits).filter(hits => !hits).length, + tracked: Object.values(fc.lineHits).length, + }; + }), + }); +}; + +export const FileExplorer = () => { + const { entity } = useEntity(); + const [curData, setCurData] = useState(); + const [tableData, setTableData] = useState(); + const [curPath, setCurPath] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [curFile, setCurFile] = useState(''); + const codeCoverageApi = useApi(codeCoverageApiRef); + const { loading, error, value } = useAsync( + async () => + await codeCoverageApi.getCoverageForEntity({ + kind: entity.kind, + namespace: entity.metadata.namespace || 'default', + name: entity.metadata.name, + }), + ); + + useEffect(() => { + if (!value) return; + const data = formatInitialData(value); + setCurData(data); + if (data.files) setTableData(data.files); + }, [value]); + + if (loading) { + return ; + } else if (error) { + return ; + } + if (!value) { + return ( + No code coverage found for ${entity} + ); + } + + const moveDownIntoPath = (path: string) => { + const nextPathData = tableData!.find( + (d: CoverageTableRow) => d.path === path, + ); + if (nextPathData && nextPathData.files) { + setTableData(nextPathData.files); + } + }; + + const moveUpIntoPath = (path: string) => { + const pathArray = path.split('/').filter(p => p.length); + let data = curData?.files; + pathArray.forEach(p => { + data = data?.find(d => d.path === p)?.files; + }); + setCurPath(path); + setTableData(data); + }; + + const columns: TableColumn[] = [ + { + title: 'Path', + type: 'string', + field: 'path', + render: (row: CoverageTableRow) => { + if (row.files?.length) { + return ( +
{ + setCurPath(`${curPath}/${row.path}`); + moveDownIntoPath(row.path); + }} + onClick={() => { + setCurPath(`${curPath}/${row.path}`); + moveDownIntoPath(row.path); + }} + > + {row.path} +
+ ); + } + + return ( + + {row.path} + + { + setCurFile(`${curPath.slice(1)}/${row.path}`); + setModalOpen(true); + }} + /> + + + ); + }, + }, + { + title: 'Coverage', + type: 'numeric', + field: 'coverage', + render: (row: CoverageTableRow) => `${row.coverage}%`, + }, + { + title: 'Missing lines', + type: 'numeric', + field: 'missing', + }, + { + title: 'Tracked lines', + type: 'numeric', + field: 'tracked', + }, + ]; + + const pathArray = curPath.split('/'); + const lastPathElementIndex = pathArray.length - 1; + const fileCoverage = value.files.find((f: FileEntry) => + f.filename.endsWith(curFile), + ); + + if (!fileCoverage) { + return null; + } + + return ( + + + + + {pathArray.map((pathElement, idx) => ( + +
moveUpIntoPath(pathElement)} + onClick={() => moveUpIntoPath(pathElement)} + > + {pathElement || 'root'} +
+
{'\u00A0/\u00A0'}
+
+ ))} +
+ No files found} + data={tableData || []} + columns={columns} + /> + event.stopPropagation()} + onClose={() => setModalOpen(false)} + style={{ overflow: 'scroll' }} + > + + + + + ); +}; diff --git a/plugins/code-coverage/src/components/FileExplorer/Highlighter.ts b/plugins/code-coverage/src/components/FileExplorer/Highlighter.ts new file mode 100644 index 0000000000..39f56200b6 --- /dev/null +++ b/plugins/code-coverage/src/components/FileExplorer/Highlighter.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import 'highlight.js/styles/atom-one-dark.css'; +import highlight from 'highlight.js'; + +/* + * Given a file extension, repo name, and array of code lines, return a Promise resolving + * to an array of formatted lines with html/css formatting. + * + * @param fileExtension The extension of the source file + * @param lines The source code lines + * + * @returns Promise of formatted lines + * + * @see http://highlightjs.readthedocs.io/en/latest/api.html#highlight-name-value-ignore-illegals-continuation + */ +export const highlightLines = (fileExtension: string, lines: Array) => { + const formattedLines: Array = []; + let state: CompiledMode | Language | undefined; + let fileformat = fileExtension; + if (fileExtension === 'm') { + fileformat = 'objectivec'; + } + if (fileExtension === 'tsx') { + fileformat = 'typescript'; + } + if (fileExtension === 'jsx') { + fileformat = 'javascript'; + } + if (fileExtension === 'kt') { + fileformat = 'kotlin'; + } + + lines.forEach(line => { + const result = highlight.highlight(fileformat, line, true, state); + state = result.top; + formattedLines.push(result.value); + }); + return formattedLines; +}; diff --git a/plugins/code-coverage/src/components/FileExplorer/index.ts b/plugins/code-coverage/src/components/FileExplorer/index.ts new file mode 100644 index 0000000000..88da4059bd --- /dev/null +++ b/plugins/code-coverage/src/components/FileExplorer/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FileExplorer } from './FileExplorer'; diff --git a/plugins/code-coverage/src/components/Router.tsx b/plugins/code-coverage/src/components/Router.tsx new file mode 100644 index 0000000000..12be6ef5a8 --- /dev/null +++ b/plugins/code-coverage/src/components/Router.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { MissingAnnotationEmptyState } from '@backstage/core'; +import { CodeCoveragePage } from './CodeCoveragePage'; + +export const isCodeCoverageAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.['backstage.io/code-coverage']); + +export const Router = () => { + const { entity } = useEntity(); + + if (!isCodeCoverageAvailable(entity)) { + return ( + + ); + } + return ; +}; diff --git a/plugins/code-coverage/src/index.ts b/plugins/code-coverage/src/index.ts new file mode 100644 index 0000000000..7542f963b5 --- /dev/null +++ b/plugins/code-coverage/src/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { codeCoveragePlugin, EntityCodeCoverageContent } from './plugin'; +export { + Router, + isCodeCoverageAvailable, + isCodeCoverageAvailable as isPluginApplicableToEntity, +} from './components/Router'; diff --git a/plugins/code-coverage/src/plugin.test.ts b/plugins/code-coverage/src/plugin.test.ts new file mode 100644 index 0000000000..9a34c66965 --- /dev/null +++ b/plugins/code-coverage/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { codeCoveragePlugin } from './plugin'; + +describe('code-coverage', () => { + it('should export plugin', () => { + expect(codeCoveragePlugin).toBeDefined(); + }); +}); diff --git a/plugins/code-coverage/src/plugin.ts b/plugins/code-coverage/src/plugin.ts new file mode 100644 index 0000000000..68e8291bc4 --- /dev/null +++ b/plugins/code-coverage/src/plugin.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createApiFactory, + createPlugin, + createRoutableExtension, + discoveryApiRef, +} from '@backstage/core'; +import { codeCoverageApiRef, CodeCoverageRestApi } from './api'; + +import { rootRouteRef } from './routes'; + +export const codeCoveragePlugin = createPlugin({ + id: 'code-coverage', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: codeCoverageApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CodeCoverageRestApi(discoveryApi), + }), + ], +}); + +export const EntityCodeCoverageContent = codeCoveragePlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/code-coverage/src/routes.ts b/plugins/code-coverage/src/routes.ts new file mode 100644 index 0000000000..9eaf7859f5 --- /dev/null +++ b/plugins/code-coverage/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + title: 'code-coverage', +}); diff --git a/plugins/code-coverage/src/setupTests.ts b/plugins/code-coverage/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/code-coverage/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/code-coverage/src/types.ts b/plugins/code-coverage/src/types.ts new file mode 100644 index 0000000000..34fcf57968 --- /dev/null +++ b/plugins/code-coverage/src/types.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EntityName } from '@backstage/catalog-model'; + +export type JsonCodeCoverage = { + metadata: CoverageMetadata; + entity: EntityName; + files: Array; +}; + +export type JsonCoverageHistory = { + entity: EntityName; + history: Array; +}; + +export type CoverageHistory = { + line: { + available: number; + covered: number; + }; + branch: BranchHit; +}; + +export type CoverageMetadata = { + vcs: { + type: string; + location: string; + }; + generationTime: number; +}; + +export type BranchHit = { + covered: number; + missed: number; + available: number; +}; + +export type FileEntry = { + filename: string; + lineHits: Record; + branchHits: Record; +}; + +export type AggregateCoverage = { + timestamp: number; + line: { + available: number; + covered: number; + missed: number; + percentage: number; + }; + branch: { + available: number; + covered: number; + missed: number; + percentage: number; + }; +}; diff --git a/plugins/config-schema/dev/example-schema.json b/plugins/config-schema/dev/example-schema.json index d1e577ff6f..ac5c2ad347 100644 --- a/plugins/config-schema/dev/example-schema.json +++ b/plugins/config-schema/dev/example-schema.json @@ -1247,7 +1247,7 @@ "required": ["accountToken"], "properties": { "accountToken": { - "description": "The autentication token for accessing the Rollbar API", + "description": "The authentication token for accessing the Rollbar API", "type": "string" }, "organization": { @@ -1320,7 +1320,7 @@ "type": "string" }, "properties": { - "visiblity": { + "visibility": { "description": "The visibility to set on created repositories.", "enum": ["internal", "private", "public"], "type": "string" @@ -1337,7 +1337,7 @@ "type": "string" } }, - "visiblity": { + "visibility": { "description": "The visibility to set on created repositories.", "enum": ["internal", "private", "public"], "type": "string" @@ -1369,7 +1369,7 @@ "type": "string" } }, - "visiblity": { + "visibility": { "description": "The visibility to set on created repositories.", "enum": ["private", "public"], "type": "string" diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index 2db05d549a..a017529400 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index 47fa9633bf..c86a71f8c9 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cost-insights +## 0.8.5 + +### Patch Changes + +- b98de52ae: Support a `name` prop for Projects for display purposes +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.8.4 ### Patch Changes diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index bbd047be14..f737c022f3 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -51,11 +51,17 @@ export const apis = [ ]; ``` -4. Add cost-insights to your Backstage plugins. +4. Add the `CostInsightsPage` extension to your `App.tsx`: -```ts -// packages/app/src/plugins.ts -export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; +```tsx +// packages/app/src/App.tsx +import { CostInsightsPage } from '@backstage/plugin-cost-insights'; + + + ... + } /> + ... +; ``` 5. Add Cost Insights to your app Sidebar. diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index bec5db9fe0..39e3fe1791 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.8.4", + "version": "0.8.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -55,7 +55,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index f8c06845c9..7133ae81e3 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -46,6 +46,7 @@ describe.each` engineerCost | ratio | amount | expected ${200_000} | ${0} | ${0} | ${'Negligible'} ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${undefined} | ${10_000} | ${`~1 ${engineers.unit}`} ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~1 ${engineers.unit}`} ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~1 ${engineers.unit}`} ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~1 ${engineers.unit}`} @@ -65,6 +66,9 @@ describe.each` engineerCost | ratio | amount | expected ${200_000} | ${0} | ${0} | ${'Negligible'} ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${undefined} | ${-1_000} | ${'Negligible'} + ${200_000} | ${undefined} | ${1_000} | ${'Negligible'} + ${200_000} | ${undefined} | ${10_000} | ${'~$10,000'} ${200_000} | ${0.000000001} | ${8_334} | ${'0% or ~$8,334'} ${200_000} | ${-0.000000001} | ${10_000} | ${'0% or ~$10,000'} ${200_000} | ${-0.8} | ${10_000} | ${'80% or ~$10,000'} @@ -84,6 +88,8 @@ describe.each` engineerCost | ratio | amount | expected ${200_000} | ${0} | ${0} | ${'Negligible'} ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${undefined} | ${1_000} | ${'Negligible'} + ${200_000} | ${undefined} | ${10_000} | ${`~2,857 ${carbon.unit}s`} ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~2,381 ${carbon.unit}s`} ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~2,857 ${carbon.unit}s`} ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~2,857 ${carbon.unit}s`} diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index 01a0874636..196f8bec44 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -29,6 +29,7 @@ import { useCostGrowthStyles as useStyles } from '../../utils/styles'; import { formatPercent, formatCurrency } from '../../utils/formatters'; import { indefiniteArticleOf } from '../../utils/grammar'; import { useConfig, useCurrency } from '../../hooks'; +import { notEmpty } from '../../utils/assert'; export type CostGrowthProps = { change: ChangeStatistic; @@ -42,31 +43,65 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => { // Only display costs in absolute values const amount = Math.abs(change.amount); - const ratio = Math.abs(change.ratio); + const ratio = Math.abs(change.ratio ?? NaN); const rate = rateOf(engineerCost, duration); const engineers = amount / rate; const converted = amount / (currency.rate ?? rate); + // If a ratio cannot be calculated, don't format. + const growth = notEmpty(change.ratio) + ? growthOf({ ratio: change.ratio, amount: engineers }) + : null; // Determine if growth is significant enough to highlight - const growth = growthOf(change.ratio, engineers); const classes = classnames({ [styles.excess]: growth === GrowthType.Excess, [styles.savings]: growth === GrowthType.Savings, }); - const percent = formatPercent(ratio); - - let cost = `${percent} or ~${formatCurrency(converted, currency.unit)}`; - // Always display the converted value but use the cost in engineers - // to determine negligibility, as costs should be time-period aware if (engineers < EngineerThreshold) { - cost = 'Negligible'; - } else if (currency.kind === CurrencyType.USD) { - cost = `${percent} or ~${currency.prefix}${formatCurrency(converted)}`; - } else if (amount < 1) { - cost = `less than ${indefiniteArticleOf(['a', 'an'], currency.unit)}`; + return Negligible; } - return {cost}; + if (currency.kind === CurrencyType.USD) { + // Do not display percentage if ratio cannot be calculated + if (isNaN(ratio)) { + return ( + + ~{currency.prefix} + {formatCurrency(converted)} + + ); + } + + return ( + + {formatPercent(ratio)} or ~{currency.prefix} + {formatCurrency(converted)} + + ); + } + + if (amount < 1) { + return ( + + less than {indefiniteArticleOf(['a', 'an'], currency.unit)} + + ); + } + + // Do not display percentage if ratio cannot be calculated + if (isNaN(ratio)) { + return ( + + ~{formatCurrency(converted, currency.unit)} + + ); + } + + return ( + + {formatPercent(ratio)} or ~{formatCurrency(converted, currency.unit)} + + ); }; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx index 8be5e238cc..6dc6f2cc93 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx @@ -21,8 +21,6 @@ import { ChangeThreshold, EngineerThreshold } from '../../types'; describe.each` ratio | amount | ariaLabel - ${-0.1} | ${undefined} | ${'savings'} - ${0.01} | ${undefined} | ${'excess'} ${ChangeThreshold.lower} | ${EngineerThreshold} | ${'savings'} ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${'savings'} ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${'savings'} @@ -32,7 +30,7 @@ describe.each` `('growthOf', ({ ratio, amount, ariaLabel }) => { it(`should display the correct indicator for ${ariaLabel}`, async () => { const { getByLabelText } = await renderInTestApp( - , + , ); expect(getByLabelText(ariaLabel)).toBeInTheDocument(); }); @@ -40,7 +38,8 @@ describe.each` describe.each` ratio | amount - ${0} | ${undefined} + ${undefined} | ${0} + ${0} | ${0} ${ChangeThreshold.lower} | ${0} ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} @@ -49,7 +48,7 @@ describe.each` `('growthOf', ({ ratio, amount }) => { it('should display the correct indicator for negligible growth', async () => { const { queryByLabelText } = await renderInTestApp( - , + , ); expect(queryByLabelText('savings')).not.toBeInTheDocument(); expect(queryByLabelText('excess')).not.toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx index e220fdb8e7..a1c200c6b3 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx @@ -20,49 +20,33 @@ import { Typography, TypographyProps } from '@material-ui/core'; import { default as ArrowDropUp } from '@material-ui/icons/ArrowDropUp'; import { default as ArrowDropDown } from '@material-ui/icons/ArrowDropDown'; import { growthOf } from '../../utils/change'; -import { GrowthType } from '../../types'; +import { ChangeStatistic, GrowthType, Maybe } from '../../types'; import { useCostGrowthStyles as useStyles } from '../../utils/styles'; export type CostGrowthIndicatorProps = TypographyProps & { - ratio: number; - amount?: number; - formatter?: (amount: number) => string; + change: ChangeStatistic; + formatter?: (change: ChangeStatistic) => Maybe; }; export const CostGrowthIndicator = ({ - ratio, - amount, + change, formatter, className, ...props }: CostGrowthIndicatorProps) => { const classes = useStyles(); - const growth = growthOf(ratio, amount); + const growth = growthOf(change); const classNames = classnames(classes.indicator, className, { - [classes.savings]: growth === GrowthType.Savings, [classes.excess]: growth === GrowthType.Excess, + [classes.savings]: growth === GrowthType.Savings, }); - // Display cost as a factor of engineer cost growth and percentage growth - if (typeof amount === 'number') { - return ( - - {formatter ? formatter(amount) : amount} - {growth === GrowthType.Savings && ( - - )} - {growth === GrowthType.Excess && } - - ); - } - - // Display cost as a factor of percent change return ( - {formatter ? formatter(ratio) : ratio} - {ratio < 0 && } - {ratio > 0 && } + {formatter ? formatter(change) : change.ratio} + {growth === GrowthType.Excess && } + {growth === GrowthType.Savings && } ); }; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx new file mode 100644 index 0000000000..112081fed4 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx @@ -0,0 +1,153 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { CostOverviewLegend } from './CostOverviewLegend'; +import { + MockBillingDateProvider, + MockConfigProvider, + MockFilterProvider, + MockCurrencyProvider, +} from '../../testUtils'; + +function renderInTestApp(children: JSX.Element) { + return render( + wrapInTestApp( + + + + {children} + + + , + ), + ); +} + +describe('', () => { + it('displays the legend without exploding', async () => { + const { findByText } = renderInTestApp( + , + ); + + expect(await findByText('Cost Trend')).toBeInTheDocument(); + expect(await findByText('MSC Trend')).toBeInTheDocument(); + }); + + it('does not display metric legend if metric data is not provided', async () => { + const { findByText, queryByText } = renderInTestApp( + , + ); + + expect(await findByText('Cost Trend')).toBeInTheDocument(); + expect(queryByText('MSC Trend')).not.toBeInTheDocument(); + }); +}); + +describe.each` + ratio | amount | title | expected + ${undefined} | ${1_000} | ${'∞'} | ${'Your Excess'} + ${undefined} | ${-1_000} | ${'-∞'} | ${'Your Savings'} +`('', ({ ratio, amount, title, expected }) => { + it('displays the correct legend if ratio cannot be calculated and costs are within time period', async () => { + const { findByText, findAllByText } = renderInTestApp( + , + ); + + expect(await findByText('Cost Trend')).toBeInTheDocument(); + expect(await findByText('MSC Trend')).toBeInTheDocument(); + expect(await findAllByText(title).then(res => res.length)).toBe(2); + expect(await findByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx index ebba5173a9..ad8a065a6e 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx @@ -25,9 +25,9 @@ import { Metric, } from '../../types'; import { useLastCompleteBillingDate, useFilters } from '../../hooks'; -import { getComparedChange } from '../../utils/change'; +import { getComparedChange, choose } from '../../utils/change'; import { mapFiltersToProps } from './selector'; -import { formatPercent } from '../../utils/formatters'; +import { formatChange } from '../../utils/formatters'; import { CostGrowth } from '../CostGrowth'; type CostOverviewLegendProps = { @@ -42,9 +42,8 @@ export const CostOverviewLegend = ({ metricData, }: PropsWithChildren) => { const theme = useTheme(); - - const lastCompleteBillingDate = useLastCompleteBillingDate(); const { duration } = useFilters(mapFiltersToProps); + const lastCompleteBillingDate = useLastCompleteBillingDate(); const comparedChange = metricData ? getComparedChange( @@ -57,23 +56,25 @@ export const CostOverviewLegend = ({ return ( - - - {formatPercent(dailyCostData.change!.ratio)} - - - {metric && metricData && comparedChange && ( + {dailyCostData.change && ( + + + {formatChange(dailyCostData.change)} + + + )} + {metricData && metric && comparedChange && ( <> - {formatPercent(metricData.change.ratio)} + {formatChange(metricData.change)} diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx index 45576bda65..f9bb44058b 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx @@ -18,10 +18,10 @@ import React from 'react'; import classnames from 'classnames'; import { Table, TableColumn } from '@backstage/core'; import { Typography } from '@material-ui/core'; -import { costFormatter, formatPercent } from '../../utils/formatters'; +import { costFormatter, formatChange } from '../../utils/formatters'; import { useEntityDialogStyles as useStyles } from '../../utils/styles'; import { CostGrowthIndicator } from '../CostGrowth'; -import { BarChartOptions, Entity } from '../../types'; +import { BarChartOptions, ChangeStatistic, Entity } from '../../types'; export type ProductEntityTableOptions = Partial< Pick @@ -32,7 +32,7 @@ type RowData = { label: string; previous: number; current: number; - ratio: number; + change: ChangeStatistic; }; function createRenderer(col: keyof RowData, classes: Record) { @@ -41,7 +41,7 @@ function createRenderer(col: keyof RowData, classes: Record) { const rowStyles = classnames(classes.row, { [classes.rowTotal]: row.id === 'total', [classes.colFirst]: col === 'label', - [classes.colLast]: col === 'ratio', + [classes.colLast]: col === 'change', }); switch (col) { @@ -52,12 +52,12 @@ function createRenderer(col: keyof RowData, classes: Record) { {costFormatter.format(row[col])} ); - case 'ratio': + case 'change': return ( formatPercent(Math.abs(amount))} + change={row.change} + formatter={formatChange} /> ); default: @@ -75,10 +75,15 @@ function createSorter(field?: keyof Omit) { if (a.id === 'total') return 1; if (b.id === 'total') return 1; if (field === 'label') return a.label.localeCompare(b.label); + if (field === 'change') { + if (formatChange(a[field]) === '∞' || formatChange(b[field]) === '-∞') + return 1; + if (formatChange(a[field]) === '-∞' || formatChange(b[field]) === '∞') + return -1; + return a[field].ratio! - b[field].ratio!; + } - return field - ? a[field] - b[field] - : b.previous + b.current - (a.previous + a.current); + return b.previous + b.current - (a.previous + a.current); }; } @@ -134,11 +139,11 @@ export const ProductEntityTable = ({ customSort: createSorter('current'), }, { - field: 'ratio', + field: 'change', title: Change, align: 'right', - render: createRenderer('ratio', classes), - customSort: createSorter('ratio'), + render: createRenderer('change', classes), + customSort: createSorter('change'), }, ]; @@ -148,14 +153,14 @@ export const ProductEntityTable = ({ label: e.id || 'Unknown', previous: e.aggregation[0], current: e.aggregation[1], - ratio: e.change.ratio, + change: e.change, })) .concat({ id: 'total', label: 'Total', previous: entity.aggregation[0], current: entity.aggregation[1], - ratio: entity.change.ratio, + change: entity.change, }) .sort(createSorter()); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx index ace2766277..07fea77d9d 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -40,7 +40,7 @@ import { findAnyKey, assertAlways, } from '../../utils/assert'; -import { formatPeriod, formatPercent } from '../../utils/formatters'; +import { formatPeriod, formatChange } from '../../utils/formatters'; import { titleOf, tooltipItemOf, @@ -54,6 +54,7 @@ import { useBarChartLayoutStyles as useLayoutStyles, } from '../../utils/styles'; import { Duration, Entity, Maybe } from '../../types'; +import { choose } from '../../utils/change'; export type ProductInsightsChartProps = { billingDate: string; @@ -86,7 +87,6 @@ export const ProductInsightsChart = ({ return breakdowns.length > 0; }, [entities, activeLabel]); - const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`; const costStart = entity.aggregation[0]; const costEnd = entity.aggregation[1]; const resources = entities.map(resourceOf); @@ -136,7 +136,6 @@ export const ProductInsightsChart = ({ const items = payload.map(tooltipItemOf).filter(notEmpty); const activeEntity = findAlways(entities, e => e.id === id); - const ratio = activeEntity.change.ratio; const breakdowns = Object.keys(activeEntity.entities); if (breakdowns.length) { @@ -148,11 +147,13 @@ export const ProductInsightsChart = ({ title={title} subtitle={subtitle} topRight={ - + !!activeEntity.change.ratio && ( + + ) } actions={ @@ -173,11 +174,13 @@ export const ProductInsightsChart = ({ + !!activeEntity.change.ratio && ( + + ) } content={ id @@ -197,7 +200,9 @@ export const ProductInsightsChart = ({ return ( - + diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx index 1bd57a9eed..1820b9e35a 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -23,7 +23,7 @@ import { renderInTestApp } from '@backstage/test-utils'; const mockProjects = [ { id: 'project1' }, - { id: 'project2' }, + { id: 'project2', name: 'Project 2' }, { id: 'project3' }, ]; @@ -56,7 +56,9 @@ describe('', () => { await waitFor(() => rendered.getByTestId('option-all')); mockProjects.forEach(project => - expect(rendered.getByText(project.id)).toBeInTheDocument(), + expect( + rendered.getByText(project.name ?? project.id), + ).toBeInTheDocument(), ); }); }); diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index bc34eb9c8d..25fdc0c171 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -33,7 +33,9 @@ export const ProjectSelect = ({ const projectOptions = projects .filter(p => p.id) - .sort((a, b) => (a.id as string).localeCompare(b.id as string)); + .sort((a, b) => + ((a.name ?? a.id) as string).localeCompare((b.name ?? b.id) as string), + ); const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { onSelect(e.target.value as string); @@ -41,9 +43,10 @@ export const ProjectSelect = ({ const renderValue = (value: unknown) => { const proj = value as string; + const projectObj = projects.find(p => p.id === proj); return ( - {proj === 'all' ? 'All Projects' : proj} + {proj === 'all' ? 'All Projects' : projectObj?.name ?? proj} ); }; @@ -52,7 +55,7 @@ export const ProjectSelect = ({ diff --git a/plugins/cost-insights/src/testUtils/mockData.ts b/plugins/cost-insights/src/testUtils/mockData.ts index 5149da278d..27bb07d0e1 100644 --- a/plugins/cost-insights/src/testUtils/mockData.ts +++ b/plugins/cost-insights/src/testUtils/mockData.ts @@ -294,7 +294,6 @@ export const MockBigQueryInsights: Entity = { id: 'dataset-c', aggregation: [0, 10_000], change: { - ratio: 10_000, amount: 10_000, }, entities: {}, @@ -415,7 +414,6 @@ export const MockCloudDataflowInsights: Entity = { id: 'pipeline-c', aggregation: [0, 10_000], change: { - ratio: 10_000, amount: 10_000, }, entities: {}, @@ -503,7 +501,6 @@ export const MockCloudStorageInsights: Entity = { id: 'Mock SKU C', aggregation: [2_000, 0], change: { - ratio: -1, amount: -2000, }, entities: {}, @@ -515,7 +512,6 @@ export const MockCloudStorageInsights: Entity = { id: 'bucket-c', aggregation: [0, 0], change: { - ratio: 0, amount: 0, }, entities: {}, @@ -655,7 +651,6 @@ export const MockComputeEngineInsights: Entity = { id: 'service-c', aggregation: [0, 10_000], change: { - ratio: 10_000, amount: 10_000, }, entities: {}, diff --git a/plugins/cost-insights/src/testUtils/testUtils.ts b/plugins/cost-insights/src/testUtils/testUtils.ts index 37913f635e..5077e05b75 100644 --- a/plugins/cost-insights/src/testUtils/testUtils.ts +++ b/plugins/cost-insights/src/testUtils/testUtils.ts @@ -93,10 +93,16 @@ export function changeOf(aggregation: DateAggregation[]): ChangeStatistic { const lastAmount = aggregation.length ? aggregation[aggregation.length - 1].amount : 0; - const ratio = - firstAmount !== 0 ? (lastAmount - firstAmount) / firstAmount : 0; + + // if either the first or last amounts are zero, the rate of increase/decrease is infinite + if (!firstAmount || !lastAmount) { + return { + amount: lastAmount - firstAmount, + }; + } + return { - ratio: ratio, + ratio: (lastAmount - firstAmount) / firstAmount, amount: lastAmount - firstAmount, }; } diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts index a47640411a..70cd9fb9a2 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -16,7 +16,9 @@ export interface ChangeStatistic { // The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum - ratio: number; + // If a ratio cannot be calculated - such as when a new or old sum is zero, + // the ratio can be omitted and where applicable, ∞ or -∞ will display based on amount. + ratio?: number; // The actual USD change between time periods (can be negative if costs decreased) amount: number; } diff --git a/plugins/cost-insights/src/types/Project.ts b/plugins/cost-insights/src/types/Project.ts index 24259f6910..6ac61f1fb7 100644 --- a/plugins/cost-insights/src/types/Project.ts +++ b/plugins/cost-insights/src/types/Project.ts @@ -16,4 +16,5 @@ export interface Project { id: string; + name?: string; } diff --git a/plugins/cost-insights/src/utils/assert.ts b/plugins/cost-insights/src/utils/assert.ts index 05ce65197b..5f9f0d01e1 100644 --- a/plugins/cost-insights/src/utils/assert.ts +++ b/plugins/cost-insights/src/utils/assert.ts @@ -20,7 +20,7 @@ export function notEmpty( return !isNull(value) && !isUndefined(value); } -export function isUndefined(value: any): boolean { +export function isUndefined(value: any): value is undefined { return value === undefined; } diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index 7208e9bc4f..191ae589d6 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -32,23 +32,23 @@ const GrowthMap = { describe.each` ratio | amount | expected - ${0.0} | ${undefined} | ${GrowthType.Negligible} + ${undefined} | ${0} | ${GrowthType.Negligible} + ${0.0} | ${0} | ${GrowthType.Negligible} ${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible} ${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible} - ${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${0} | ${GrowthType.Negligible} ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible} ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible} ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible} - ${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible} + ${ChangeThreshold.lower - 0.01} | ${0} | ${GrowthType.Negligible} + ${ChangeThreshold.upper} | ${0} | ${GrowthType.Negligible} + ${ChangeThreshold.upper - 0.01} | ${0} | ${GrowthType.Negligible} ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible} - ${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings} + ${ChangeThreshold.upper + 0.01} | ${0} | ${GrowthType.Negligible} ${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings} - ${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings} ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings} ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings} - ${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess} ${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess} - ${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess} ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess} ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess} `( @@ -63,7 +63,7 @@ describe.each` expected: GrowthType; }) => { it(`should display ${GrowthMap[expected]}`, () => { - expect(growthOf(ratio, amount)).toBe(expected); + expect(growthOf({ ratio, amount })).toBe(expected); }); }, ); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index 950fbfeb82..479818bdae 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -27,21 +27,26 @@ import { import dayjs, { OpUnitType } from 'dayjs'; import durationPlugin from 'dayjs/plugin/duration'; import { inclusiveStartDateOf } from './duration'; +import { notEmpty } from './assert'; dayjs.extend(durationPlugin); // Used for displaying status colors -export function growthOf(ratio: number, amount?: number) { - if (typeof amount === 'number') { - if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { +export function growthOf(change: ChangeStatistic): GrowthType { + const exceedsEngineerThreshold = Math.abs(change.amount) >= EngineerThreshold; + + if (notEmpty(change.ratio)) { + if (exceedsEngineerThreshold && change.ratio >= ChangeThreshold.upper) { return GrowthType.Excess; } - if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { + + if (exceedsEngineerThreshold && change.ratio <= ChangeThreshold.lower) { return GrowthType.Savings; } } else { - if (ratio >= ChangeThreshold.upper) return GrowthType.Excess; - if (ratio <= ChangeThreshold.lower) return GrowthType.Savings; + if (exceedsEngineerThreshold && change.amount > 0) return GrowthType.Excess; + if (exceedsEngineerThreshold && change.amount < 0) + return GrowthType.Savings; } return GrowthType.Negligible; @@ -54,15 +59,24 @@ export function getComparedChange( duration: Duration, lastCompleteBillingDate: string, // YYYY-MM-DD, ): ChangeStatistic { - const ratio = dailyCost.change!.ratio - metricData.change.ratio; + const dailyCostRatio = dailyCost.change?.ratio; + const metricDataRatio = metricData.change?.ratio; const previousPeriodTotal = getPreviousPeriodTotalCost( dailyCost.aggregation, duration, lastCompleteBillingDate, ); + + // if either ratio cannot be calculated, no compared ratio can be calculated + if (!notEmpty(dailyCostRatio) || !notEmpty(metricDataRatio)) { + return { + amount: previousPeriodTotal, + }; + } + return { - ratio: ratio, - amount: previousPeriodTotal * ratio, + ratio: dailyCostRatio - metricDataRatio, + amount: previousPeriodTotal * (dailyCostRatio - metricDataRatio), }; } @@ -78,7 +92,6 @@ export function getPreviousPeriodTotalCost( ? [dayjsDuration.days(), 'day'] : [dayjsDuration.months(), 'month']; const nextPeriodStart = dayjs(startDate).add(amount, type); - // Add up costs that incurred before the start of the next period. return aggregation.reduce((acc, costByDate) => { return dayjs(costByDate.date).isBefore(nextPeriodStart) @@ -86,3 +99,11 @@ export function getPreviousPeriodTotalCost( : acc; }, 0); } + +export function choose( + [savings, excess]: [T, T], + change: ChangeStatistic, +): T { + const isSavings = (change.ratio ?? change.amount) <= 0; + return isSavings ? savings : excess; +} diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 8f2b94f997..75306d7392 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -16,8 +16,9 @@ import moment from 'moment'; import pluralize from 'pluralize'; -import { Duration } from '../types'; +import { ChangeStatistic, Duration } from '../types'; import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; +import { notEmpty } from './assert'; export type Period = { periodStart: string; @@ -78,6 +79,13 @@ export function formatCurrency(amount: number, currency?: string): string { return currency ? `${numString} ${pluralize(currency, n)}` : numString; } +export function formatChange(change: ChangeStatistic): string { + if (notEmpty(change.ratio)) { + return formatPercent(Math.abs(change.ratio)); + } + return change.amount >= 0 ? '∞' : '-∞'; +} + export function formatPercent(n: number): string { // Number.toFixed shows scientific notation for extreme numbers if (isNaN(n) || Math.abs(n) < 0.01) { diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index bebf6a8c44..78275577f5 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-explore +## 0.3.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.3 ### Patch Changes diff --git a/plugins/explore/README.md b/plugins/explore/README.md index b236af14dc..fea1333e34 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -5,13 +5,7 @@ This plugin helps to visualize the domains and tools in your ecosystem. ## Getting started -To install the plugin, include the following import your `plugins.ts`: - -```typescript -export { explorePlugin } from '@backstage/plugin-explore'; -``` - -Register and bind the route in `App.tsx`: +To install the plugin, add and bind the route in `App.tsx`: ```typescript import { ExplorePage, explorePlugin } from '@backstage/plugin-explore'; @@ -30,7 +24,7 @@ bindRoutes({ bind }) { } /> ``` -Add a link to the sidebar in `Root.tsx`: +And add a link to the sidebar in `Root.tsx`: ```typescript import LayersIcon from '@material-ui/icons/Layers'; diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 7765760906..be61bc1fd9 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/plugin-explore-react": "^0.0.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index 001b926c6d..bd7af91dca 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-fossa +## 0.2.6 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + +## 0.2.5 + +### Patch Changes + +- 40d1e11cf: Add a `FossaPage` that shows the license compliance status of all components in the catalog. + See the projects `Readme` on how to use it. +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.2.4 ### Patch Changes diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 722d8d2cf3..11f3e90e1c 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -14,15 +14,7 @@ The FOSSA Plugin displays code statistics from [FOSSA](https://fossa.com/). yarn add @backstage/plugin-fossa ``` -2. Add plugin to the app: - -```js -// packages/app/src/plugins.ts - -export { fossaPlugin } from '@backstage/plugin-fossa'; -``` - -3. Add the `EntityFossaCard` to the EntityPage: +2. Add the `EntityFossaCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx @@ -40,7 +32,7 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( ); ``` -4. Add the proxy config: +3. Add the proxy config: ```yaml # app-config.yaml @@ -57,9 +49,9 @@ fossa: organizationId: ``` -5. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens) +4. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens) -6. Add the `fossa.io/project-name` annotation to your catalog-info.yaml file: +5. Add the `fossa.io/project-name` annotation to your catalog-info.yaml file: ```yaml apiVersion: backstage.io/v1alpha1 @@ -75,3 +67,51 @@ spec: owner: CNCF lifecycle: experimental ``` + +## Other Components + +### FOSSA Overview Page + +The plugin provides an optional page that can be used to check the license compliance of all components. + +![FOSSA Overview](./docs/fossa-overview.png) + +Add it to your Backstage application: + +1. Install the FOSSA Plugin (see [Getting Started](#getting-started)). + +2. Register the page: + +```tsx +// packages/app/src/App.tsx + +import { FossaPage } from '@backstage/plugin-fossa'; + +// ... + +const routes = ( + + // ... + } /> + +); +``` + +3. (Optional) Add a Sidebar Icon: + +```tsx +// packages/app/src/components/Root/Root.tsx + +// ... + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + // ... + + // ... + + {children} + +); +``` diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx index 06c71c48b7..563c63faf5 100644 --- a/plugins/fossa/dev/index.tsx +++ b/plugins/fossa/dev/index.tsx @@ -14,15 +14,20 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { Content, Header, Page } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { EntityFossaCard } from '../src'; -import { FossaApi, fossaApiRef } from '../src/api'; -import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/useProjectName'; +import { EntityFossaCard, fossaPlugin } from '../src'; +import { FindingSummary, FossaApi, fossaApiRef } from '../src/api'; +import { FossaPage } from '../src/components/FossaPage'; +import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/getProjectName'; const entity = (name?: string) => ({ @@ -34,9 +39,16 @@ const entity = (name?: string) => }, name: name, }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'Group', namespace: 'default', name }, + }, + ], } as Entity); createDevApp() + .registerPlugin(fossaPlugin) .registerApi({ api: fossaApiRef, deps: {}, @@ -81,8 +93,63 @@ createDevApp() return undefined; } }, + getFindingSummaries: async () => { + await new Promise(r => setTimeout(r, 1000)); + + return new Map([ + [ + 'zero-deps', + { + timestamp: '2000-01-01T00:00:00Z', + projectUrl: '', + projectDefaultBranch: 'master', + issueCount: 0, + dependencyCount: 0, + }, + ], + [ + 'issues', + { + timestamp: '2001-01-01T00:00:00Z', + projectUrl: '', + projectDefaultBranch: 'develop', + issueCount: 10, + dependencyCount: 15, + }, + ], + [ + 'no-issues', + { + timestamp: '2002-01-01T00:00:00Z', + projectUrl: '', + projectDefaultBranch: 'master', + issueCount: 0, + dependencyCount: 5, + }, + ], + ]); + }, } as FossaApi), }) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + (({ + getEntities: async () => { + await new Promise(r => setTimeout(r, 1000)); + + return { + items: [ + entity('no-fossa'), + entity('zero-deps'), + entity('issues'), + entity('no-issues'), + ], + }; + }, + } as Partial) as any), + }) .addPage({ title: 'Entity Content', element: ( @@ -130,4 +197,8 @@ createDevApp() ), }) + .addPage({ + title: 'Catalog Overview', + element: , + }) .render(); diff --git a/plugins/fossa/docs/fossa-overview.png b/plugins/fossa/docs/fossa-overview.png new file mode 100644 index 0000000000..3e9482ab55 Binary files /dev/null and b/plugins/fossa/docs/fossa-overview.png differ diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index fd94c6b906..ba7e1a381e 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-fossa", - "version": "0.2.4", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,19 +32,22 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", + "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "cross-fetch": "^3.0.6", + "luxon": "^1.26.0", + "p-limit": "^3.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/fossa/src/api/FossaApi.ts b/plugins/fossa/src/api/FossaApi.ts index 84406e1b1a..15e8b4473e 100644 --- a/plugins/fossa/src/api/FossaApi.ts +++ b/plugins/fossa/src/api/FossaApi.ts @@ -30,5 +30,19 @@ export const fossaApiRef = createApiRef({ }); export type FossaApi = { + /** + * Get the finding summary for a list of projects + * + * @param projectTitles a list of project titles in FOSSA + */ + getFindingSummaries( + projectTitles: Array, + ): Promise>; + + /** + * Get the finding summary of a single project. + * + * @param projectTitle the project title in FOSSA + */ getFindingSummary(projectTitle: string): Promise; }; diff --git a/plugins/fossa/src/api/FossaClient.test.ts b/plugins/fossa/src/api/FossaClient.test.ts index 820210e2b6..29e5b50fcd 100644 --- a/plugins/fossa/src/api/FossaClient.test.ts +++ b/plugins/fossa/src/api/FossaClient.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlPatternDiscovery, IdentityApi } from '@backstage/core'; +import { IdentityApi, UrlPatternDiscovery } from '@backstage/core'; import { msw } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -52,138 +52,330 @@ describe('FossaClient', () => { }); }); - it('should report finding summary', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res( - ctx.json([ + describe('getFindingSummary', () => { + it('should report finding summary', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const expectedQuery = + 'count=1000&page=0&sort=title%2B&organizationId=8736&title=our-service'; + if (req.url.searchParams.toString() !== expectedQuery) { + return res( + ctx.status(500), + ctx.body( + `${req.url.searchParams.toString()} !== ${expectedQuery}`, + ), + ); + } + + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should report finding summary without licensing_issue_count', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'refs/master', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 100, + dependencyCount: 160, + projectDefaultBranch: 'refs/master', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should handle empty result', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.json([])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should ignore result with invalid title', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.json([{ title: 'our-service-2' }])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should skip organizationId', async () => { + client = new FossaClient({ discoveryApi, identityApi }); + + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const expectedQuery = + 'count=1000&page=0&sort=title%2B&title=our-service'; + if (req.url.searchParams.toString() !== expectedQuery) { + return res( + ctx.status(500), + ctx.body( + `${req.url.searchParams.toString()} !== ${expectedQuery}`, + ), + ); + } + + return res(ctx.json([])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should handle 404 status', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.status(404)); + }), + ); + + await expect(client.getFindingSummary('our-service')).rejects.toThrow(); + }); + }); + + describe('getFindingSummaries', () => { + it('should report finding summary', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const expectedQuery = + 'count=1000&page=0&sort=title%2B&organizationId=8736'; + if (req.url.searchParams.toString() !== expectedQuery) { + return res( + ctx.status(500), + ctx.body( + `${req.url.searchParams.toString()} !== ${expectedQuery}`, + ), + ); + } + + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + { + locator: 'custom+8736/our-service-2', + title: 'our-service-2', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + { + locator: 'custom+8736/our-service-3', + title: 'our-service-3', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummaries([ + 'our-service', + 'our-service-2', + ]); + + expect(summary).toEqual( + new Map([ + [ + 'our-service', { - locator: 'custom+8736/our-service', - title: 'our-service', - default_branch: 'develop', - revisions: [ - { - updatedAt: '2020-01-01T00:00:00Z', - dependency_count: 160, - unresolved_licensing_issue_count: 5, - unresolved_issue_count: 100, - }, - ], + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', }, - ]), - ); - }), - ); - - const summary = await client.getFindingSummary('our-service'); - - expect(summary).toEqual({ - timestamp: '2020-01-01T00:00:00Z', - issueCount: 5, - dependencyCount: 160, - projectDefaultBranch: 'develop', - projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', - } as FindingSummary); - }); - - it('should report finding summary without licensing_issue_count', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res( - ctx.json([ + ], + [ + 'our-service-2', { - locator: 'custom+8736/our-service', - title: 'our-service', - default_branch: 'refs/master', - revisions: [ - { - updatedAt: '2020-01-01T00:00:00Z', - dependency_count: 160, - unresolved_issue_count: 100, - }, - ], + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service-2', }, - ]), - ); - }), - ); + ], + ]), + ); + }); - const summary = await client.getFindingSummary('our-service'); + it('should handle multiple pages', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const page = req.url.searchParams.get('page'); - expect(summary).toEqual({ - timestamp: '2020-01-01T00:00:00Z', - issueCount: 100, - dependencyCount: 160, - projectDefaultBranch: 'refs/master', - projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', - } as FindingSummary); - }); + if (page === '0') { + return res( + ctx.json( + [...Array(1000)].map(() => ({ + locator: 'custom+8736/our-service', + title: 'our-service-2', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + })), + ), + ); + } - it('should handle empty result', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res(ctx.json([])); - }), - ); + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummaries(['our-service']); - expect(summary).toBeUndefined(); - }); + expect(summary).toEqual( + new Map([ + [ + 'our-service', + { + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + }, + ], + ]), + ); + }); - it('should ignore result with invalid title', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res(ctx.json([{ title: 'our-service-2' }])); - }), - ); + it('should handle empty result', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.json([])); + }), + ); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummaries([ + 'our-service', + 'our-service-2', + ]); - expect(summary).toBeUndefined(); - }); + expect(summary).toEqual(new Map()); + }); - it('should skip organizationId', async () => { - client = new FossaClient({ discoveryApi, identityApi }); + it('should handle 404 status', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.status(404)); + }), + ); - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service', - ); - return res(ctx.status(404)); - }), - ); - - const summary = await client.getFindingSummary('our-service'); - - expect(summary).toBeUndefined(); - }); - - it('should handle 404 status', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res(ctx.status(404)); - }), - ); - - const summary = await client.getFindingSummary('our-service'); - - expect(summary).toBeUndefined(); + await expect( + client.getFindingSummaries(['our-service', 'our-service-2']), + ).rejects.toThrow(); + }); }); }); diff --git a/plugins/fossa/src/api/FossaClient.ts b/plugins/fossa/src/api/FossaClient.ts index 5693895901..e8f3ecb7cd 100644 --- a/plugins/fossa/src/api/FossaClient.ts +++ b/plugins/fossa/src/api/FossaClient.ts @@ -15,13 +15,28 @@ */ import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { ResponseError } from '@backstage/errors'; import fetch from 'cross-fetch'; +import pLimit from 'p-limit'; import { FindingSummary, FossaApi } from './FossaApi'; +type FossaProjectsResponse = { + title: string; + default_branch: string; + locator: string; + revisions: Array<{ + updatedAt: string; + unresolved_licensing_issue_count: number; + unresolved_issue_count: number; + dependency_count: number; + }>; +}; + export class FossaClient implements FossaApi { discoveryApi: DiscoveryApi; identityApi: IdentityApi; organizationId?: string; + private readonly limit = pLimit(5); constructor({ discoveryApi, @@ -37,42 +52,94 @@ export class FossaClient implements FossaApi { this.organizationId = organizationId; } - private async callApi(path: string): Promise { + private async callApi( + path: string, + query: Record, + ): Promise { const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/fossa`; const idToken = await this.identityApi.getIdToken(); - const response = await fetch(`${apiUrl}/${path}`, { - headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, - }); - if (response.status === 200) { - return await response.json(); + const response = await fetch( + `${apiUrl}/${path}?${new URLSearchParams(query).toString()}`, + { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); } - return undefined; + + return (await response.json()) as T; + } + + async *getProject( + projectTitles: Set, + ): AsyncIterable<{ title: string; summary: FindingSummary }> { + const pageSize = 1000; + + for (let page = 0; ; page++) { + const projects = await this.limit(() => + this.callApi('projects', { + count: pageSize, + page, + sort: 'title+', + ...(this.organizationId && { + organizationId: this.organizationId, + }), + ...(projectTitles.size === 1 && { + title: projectTitles.values().next().value, + }), + }), + ); + + for (const project of projects) { + if (projectTitles.has(project.title) && project.revisions.length > 0) { + const revision = project.revisions[0]; + yield { + title: project.title, + summary: { + timestamp: revision.updatedAt, + issueCount: + revision.unresolved_licensing_issue_count || + revision.unresolved_issue_count, + dependencyCount: revision.dependency_count, + projectDefaultBranch: project.default_branch, + projectUrl: `https://app.fossa.com/projects/${encodeURIComponent( + project.locator, + )}`, + }, + }; + } + } + + if (projects.length < pageSize) { + break; + } + } + } + + async getFindingSummaries( + projectTitles: string[], + ): Promise> { + const map = new Map(); + + if (projectTitles.length === 0) { + return map; + } + + for await (const { title, summary } of this.getProject( + new Set(projectTitles), + )) { + map.set(title, summary); + } + + return map; } async getFindingSummary( projectTitle: string, ): Promise { - const project = await this.callApi( - `projects?count=1&sort=title+&title=${projectTitle}${ - this.organizationId ? `&organizationId=${this.organizationId}` : '' - }`, - ); - - if (!project || project.length === 0 || project[0].title !== projectTitle) { - return undefined; - } - - const revision = project[0].revisions[0]; - return { - timestamp: revision.updatedAt, - issueCount: - revision.unresolved_licensing_issue_count || - revision.unresolved_issue_count, - dependencyCount: revision.dependency_count, - projectDefaultBranch: project[0].default_branch, - projectUrl: `https://app.fossa.com/projects/${encodeURIComponent( - project[0].locator, - )}`, - }; + const summaries = await this.getFindingSummaries([projectTitle]); + return summaries.get(projectTitle); } } diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx new file mode 100644 index 0000000000..0761c85da5 --- /dev/null +++ b/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx @@ -0,0 +1,186 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { FossaApi, fossaApiRef } from '../../api'; +import { FossaCard } from './FossaCard'; + +describe('', () => { + const fossaApi: jest.Mocked = { + getFindingSummary: jest.fn(), + getFindingSummaries: jest.fn(), + }; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(fossaApiRef, fossaApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows missing annotation', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + }, + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Missing Annotation/i)).toBeInTheDocument(); + expect(getByText('fossa.io/project-name')).toBeInTheDocument(); + }); + + it('shows error', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockRejectedValue(new Error('My Error')); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Warning: My Error/i)).toBeInTheDocument(); + }); + + it('shows empty', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockResolvedValue(undefined); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect( + getByText(/There is no Fossa project with title 'my-name'./i), + ).toBeInTheDocument(); + }); + + it('shows fossa issues', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockResolvedValue({ + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 0, + dependencyCount: 10, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Number of issues/i)).toBeInTheDocument(); + expect( + getByText( + (_, node) => + node?.textContent === + 'Based on 10 Dependencies on branch branch/default-branch.', + ), + ).toBeInTheDocument(); + }); + + it('warns about zero dependencies', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockResolvedValue({ + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 0, + dependencyCount: 0, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/No Dependencies/i)).toBeInTheDocument(); + expect( + getByText(/Please check your FOSSA project settings/i), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx index 4cc27f5d03..034ba57dfc 100644 --- a/plugins/fossa/src/components/FossaCard/FossaCard.tsx +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -17,20 +17,23 @@ import { EmptyState, InfoCard, + InfoCardVariants, MissingAnnotationEmptyState, Progress, + ResponseErrorPanel, useApi, } from '@backstage/core'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Grid, Tooltip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import React from 'react'; +import { DateTime } from 'luxon'; +import React, { PropsWithChildren } from 'react'; import { useAsync } from 'react-use'; import { fossaApiRef } from '../../api'; import { FOSSA_PROJECT_NAME_ANNOTATION, - useProjectName, -} from '../useProjectName'; + getProjectName, +} from '../getProjectName'; const useStyles = makeStyles(theme => ({ numberError: { @@ -65,109 +68,134 @@ const useStyles = makeStyles(theme => ({ }, })); -export const FossaCard = () => { +const Card = ({ + children, + disabled, + projectUrl, + variant = 'gridItem', +}: PropsWithChildren<{ + disabled?: boolean; + projectUrl?: string; + variant?: InfoCardVariants; +}>) => { + const classes = useStyles(); + + return ( + + {children} + + ); +}; +export const FossaCard = ({ variant }: { variant?: InfoCardVariants }) => { const { entity } = useEntity(); const fossaApi = useApi(fossaApiRef); - const projectTitle = useProjectName(entity); - - const { value, loading } = useAsync( + const projectTitle = getProjectName(entity); + const { value, loading, error } = useAsync( async () => projectTitle ? fossaApi.getFindingSummary(projectTitle) : undefined, [fossaApi, projectTitle], ); - const deepLink = value - ? { - title: 'View more', - link: value.projectUrl, - } - : undefined; - const classes = useStyles(); + if (error) { + return ( + + + + ); + } + + if (loading) { + return ( + + + + ); + } + + if (!projectTitle) { + return ( + + + + ); + } + + if (!value) { + return ( + + + + ); + } + return ( - <> - + - {loading && } - - {!loading && !projectTitle && ( - - )} - - {!loading && projectTitle && !value && ( - - )} - - {value && ( - +

0 || value.dependencyCount === 0 + ? classes.numberError + : classes.numberSuccess + } > - -

0 || value.dependencyCount === 0 - ? classes.numberError - : classes.numberSuccess - } - > - {value.issueCount} -

- {value.dependencyCount > 0 && ( -

Number of issues

- )} - {value.dependencyCount === 0 && ( -

- No Dependencies. -
- Please check your FOSSA project settings. -

- )} -
+ {value.issueCount} +

+ {value.dependencyCount > 0 && ( +

Number of issues

+ )} + {value.dependencyCount === 0 && ( +

+ No Dependencies. +
+ Please check your FOSSA project settings. +

+ )} +
- - Last analyzed on{' '} - {new Date(value.timestamp).toLocaleString('en-US', { - timeZone: 'UTC', - day: 'numeric', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - })} - - - Based on {value.dependencyCount} Dependencies on branch{' '} - - - {value.projectDefaultBranch} - - - . - - - )} -
- + + Last analyzed on{' '} + {DateTime.fromISO(value.timestamp).toLocaleString( + DateTime.DATETIME_MED, + )} + + + Based on {value.dependencyCount} Dependencies on branch{' '} + + {value.projectDefaultBranch} + + . + + + ); }; diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx new file mode 100644 index 0000000000..efab6ab091 --- /dev/null +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { FossaApi, fossaApiRef } from '../../api'; +import { FossaPage } from './FossaPage'; + +describe('', () => { + const catalogApi: jest.Mocked = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + getOriginLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + removeLocationById: jest.fn(), + }; + const fossaApi: jest.Mocked = { + getFindingSummary: jest.fn(), + getFindingSummaries: jest.fn(), + }; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(fossaApiRef, fossaApi).with( + catalogApiRef, + catalogApi, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows fossa issues', async () => { + const entity0: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-0', + annotations: { + 'fossa.io/project-name': 'my-name-0', + }, + }, + }; + const entity1: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-1', + annotations: { + 'fossa.io/project-name': 'my-name-1', + }, + }, + }; + const entity2: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-2', + annotations: { + 'fossa.io/project-name': 'my-name-2', + }, + }, + }; + const entity3: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-3', + }, + }; + + catalogApi.getEntities.mockResolvedValue({ + items: [entity0, entity1, entity2, entity3], + }); + + fossaApi.getFindingSummaries.mockResolvedValue( + new Map([ + [ + 'my-name-1', + { + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 0, + dependencyCount: 0, + }, + ], + [ + 'my-name-2', + { + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 10, + dependencyCount: 10, + }, + ], + ]), + ); + + const { getByText, getAllByText } = await renderInTestApp( + + + , + ); + + expect(getAllByText(/Not configured/i)).toHaveLength(2); + expect(getByText(/No dependencies/i)).toBeInTheDocument(); + expect(getByText(/0 Issues/i)).toBeInTheDocument(); + expect(getByText(/10 Issues/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.tsx new file mode 100644 index 0000000000..4a5c1b9bff --- /dev/null +++ b/plugins/fossa/src/components/FossaPage/FossaPage.tsx @@ -0,0 +1,244 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + EntityName, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +import { + Content, + Header, + Link, + Page, + StatusError, + StatusOK, + StatusWarning, + Table, + TableColumn, + TableFilter, + useApi, +} from '@backstage/core'; +import { + catalogApiRef, + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import { Tooltip } from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import { DateTime } from 'luxon'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useAsync } from 'react-use'; +import { FindingSummary, fossaApiRef } from '../../api'; +import { getProjectName } from '../getProjectName'; + +type FossaRow = { + entity: Entity; + resolved: { + name: string; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + loading: boolean; + details?: FindingSummary; + }; +}; + +const columns: TableColumn[] = [ + { + title: 'Name', + field: 'resolved.name', + highlight: true, + width: 'auto', + render: ({ entity }) => ( + + ), + }, + { + title: 'Owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), + }, + { + title: 'Status', + field: 'resolved.details.issueCount', + // We interpret missing values as '0.5' to be sorted at the end of the projects with issues. + customSort: (a, b) => + (b?.resolved?.details?.issueCount ?? 0.5) - + (a?.resolved?.details?.issueCount ?? 0.5), + render: ({ resolved }) => { + if (resolved.loading) { + return ; + } else if (!resolved.details) { + return Not configured; + } else if (resolved.details.dependencyCount === 0) { + return No dependencies; + } else if (resolved.details.issueCount > 0) { + return {resolved.details.issueCount} Issues; + } + + return {resolved.details.issueCount} Issues; + }, + }, + { + title: 'Dependencies', + field: 'resolved.details.dependencyCount', + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return details?.dependencyCount; + }, + }, + { + title: 'Branch', + field: 'resolved.details.projectDefaultBranch', + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return details?.projectDefaultBranch; + }, + }, + { + title: 'Last Updated', + field: 'resolved.details.timestamp', + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return ( + details?.timestamp && ( + + + {DateTime.fromISO(details.timestamp).toRelative({ + locale: 'en', + })} + + + ) + ); + }, + }, + { + sorting: false, + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return details && View in FOSSA; + }, + }, +]; + +const filters: TableFilter[] = [ + { column: 'Owner', type: 'multiple-select' }, + { column: 'Branch', type: 'select' }, +]; + +export const FossaPage = () => { + const catalogApi = useApi(catalogApiRef); + const fossaApi = useApi(fossaApiRef); + + // Get a list of all relevant entities + const { value: entities, loading: entitiesLoading } = useAsync(() => { + return catalogApi.getEntities({ + filter: { kind: 'Component' }, + fields: [ + 'kind', + 'metadata.namespace', + 'metadata.name', + 'metadata.annotations', + 'relations', + ], + }); + }); + + // get the project names of all entities. the idx of both lists match. + const projectNames = useMemo( + () => entities?.items?.map(getProjectName) ?? [], + [entities?.items], + ); + + // get the summary list + const { value: summaries, loading: summariesLoading } = useAsync( + async () => + await fossaApi.getFindingSummaries( + projectNames.filter(n => n !== undefined) as string[], + ), + [projectNames], + ); + + // compose the rows + const rows: FossaRow[] = useMemo( + () => + entities?.items?.map((entity, idx) => { + const projectName = projectNames[idx]; + const summary = projectName ? summaries?.get(projectName) : undefined; + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity, + resolved: { + name: formatEntityRefTitle(entity), + ownedByRelations, + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + loading: summariesLoading, + details: summary, + }, + }; + }) ?? [], + [projectNames, entities?.items, summaries, summariesLoading], + ); + + return ( + +
+ + + columns={columns} + data={rows} + filters={filters} + isLoading={entitiesLoading} + options={{ + pageSize: 20, + actionsColumnIndex: -1, + loadingType: 'linear', + padding: 'dense', + showEmptyDataSourceMessage: !entitiesLoading, + }} + /> + + + ); +}; diff --git a/plugins/fossa/src/components/FossaPage/index.ts b/plugins/fossa/src/components/FossaPage/index.ts new file mode 100644 index 0000000000..7d6481949e --- /dev/null +++ b/plugins/fossa/src/components/FossaPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FossaPage } from './FossaPage'; diff --git a/plugins/fossa/src/components/getProjectName.test.ts b/plugins/fossa/src/components/getProjectName.test.ts new file mode 100644 index 0000000000..93f5187890 --- /dev/null +++ b/plugins/fossa/src/components/getProjectName.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { getProjectName } from './getProjectName'; + +describe('getProjectName', () => { + it('should extract the fossa project name', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'fossa.io/project-name': 'test', + }, + }, + }; + + expect(getProjectName(entity)).toBe('test'); + }); + + it('should return undefined', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + }, + }; + + expect(getProjectName(entity)).toBeUndefined(); + }); +}); diff --git a/plugins/fossa/src/components/useProjectName.ts b/plugins/fossa/src/components/getProjectName.ts similarity index 92% rename from plugins/fossa/src/components/useProjectName.ts rename to plugins/fossa/src/components/getProjectName.ts index 88379ca3fa..3f117dbbda 100644 --- a/plugins/fossa/src/components/useProjectName.ts +++ b/plugins/fossa/src/components/getProjectName.ts @@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model'; export const FOSSA_PROJECT_NAME_ANNOTATION = 'fossa.io/project-name'; -export const useProjectName = (entity: Entity): string | undefined => { +export const getProjectName = (entity: Entity): string | undefined => { return ( entity?.metadata.annotations?.[FOSSA_PROJECT_NAME_ANNOTATION] ?? undefined ); diff --git a/plugins/fossa/src/extensions.tsx b/plugins/fossa/src/extensions.tsx index 38573c2d1f..21e53996ba 100644 --- a/plugins/fossa/src/extensions.tsx +++ b/plugins/fossa/src/extensions.tsx @@ -14,8 +14,12 @@ * limitations under the License. */ -import { createComponentExtension } from '@backstage/core'; +import { + createComponentExtension, + createRoutableExtension, +} from '@backstage/core'; import { fossaPlugin } from './plugin'; +import { rootRoute } from './routes'; export const EntityFossaCard = fossaPlugin.provide( createComponentExtension({ @@ -24,3 +28,10 @@ export const EntityFossaCard = fossaPlugin.provide( }, }), ); + +export const FossaPage = fossaPlugin.provide( + createRoutableExtension({ + component: () => import('./components/FossaPage').then(m => m.FossaPage), + mountPoint: rootRoute, + }), +); diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts index f1b645c29b..929983bb6a 100644 --- a/plugins/fossa/src/index.ts +++ b/plugins/fossa/src/index.ts @@ -15,4 +15,4 @@ */ export { fossaPlugin } from './plugin'; -export { EntityFossaCard } from './extensions'; +export { EntityFossaCard, FossaPage } from './extensions'; diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts index 638be4a0bf..849769d42c 100644 --- a/plugins/fossa/src/plugin.ts +++ b/plugins/fossa/src/plugin.ts @@ -22,6 +22,7 @@ import { identityApiRef, } from '@backstage/core'; import { fossaApiRef, FossaClient } from './api'; +import { rootRoute } from './routes'; export const fossaPlugin = createPlugin({ id: 'fossa', @@ -41,4 +42,7 @@ export const fossaPlugin = createPlugin({ }), }), ], + routes: { + fossaOverview: rootRoute, + }, }); diff --git a/plugins/fossa/src/routes.ts b/plugins/fossa/src/routes.ts new file mode 100644 index 0000000000..6e8ce07b29 --- /dev/null +++ b/plugins/fossa/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from '@backstage/core'; + +export const rootRoute = createRouteRef({ + id: 'fossa-overview', +}); diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index fecf9bdf22..0930c791e4 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index a2c6e6bf1f..6896a7f877 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-github-actions +## 0.4.4 + +### Patch Changes + +- 4c42ecca2: Wrap EmptyState in Card +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.4.3 ### Patch Changes diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index d47144351e..87c68839c1 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -30,22 +30,34 @@ TBD spec: type: website lifecycle: production - owner: guest + owner: user:guest ``` ### Standalone app requirements -If you didn't clone this repo you have to do some extra work. - -1. Add plugin API to your Backstage instance: +1. Install the plugin dependency in your Backstage app package: ```bash +cd packages/app yarn add @backstage/plugin-github-actions ``` -```js -// packages/app/src/plugins.ts -export { plugin as GithubActions } from '@backstage/plugin-github-actions'; +2. Add to the app `EntityPage` component: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EntityGithubActionsContent } from '@backstage/plugin-github-actions'; + +// ... +const serviceEntityPage = ( + + ... + + + + ... + +); ``` 2. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`. diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index a765bd395d..5b347388f7 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -9,4 +9,4 @@ metadata: spec: type: website lifecycle: production - owner: guest + owner: user:guest diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 3dba840b8b..2c26061cf3 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.4.3", + "version": "0.4.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,9 +34,9 @@ "dependencies": { "@backstage/catalog-model": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/integration": "^0.5.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index 1126c2a2a0..f0366e5590 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -16,21 +16,21 @@ import { Entity } from '@backstage/catalog-model'; import { configApiRef, - EmptyState, errorApiRef, InfoCard, InfoCardVariants, + Link, Table, useApi, } from '@backstage/core'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { Button, Link } from '@material-ui/core'; import React, { useEffect } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { Typography } from '@material-ui/core'; const firstLine = (message: string): string => message.split('\n')[0]; @@ -74,54 +74,53 @@ export const RecentWorkflowRunsCard = ({ const githubHost = hostname || 'github.com'; - return !runs.length ? ( - - Create new Workflow - - } - /> - ) : ( + return ( -
( - - {firstLine(data.message)} - - ), - }, - { title: 'Branch', field: 'source.branchName' }, - { title: 'Status', field: 'status', render: WorkflowRunStatus }, - ]} - data={runs} - /> + {!runs.length ? ( +
+ + This component has GitHub Actions enabled, but no workflows were + found. + + + + Create a new workflow + + +
+ ) : ( +
( + + {firstLine(data.message)} + + ), + }, + { title: 'Branch', field: 'source.branchName' }, + { title: 'Status', field: 'status', render: WorkflowRunStatus }, + ]} + data={runs} + /> + )} ); }; diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 9369ef87f9..23c8943349 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/plugin-github-deployments +## 0.1.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + +## 0.1.3 + +### Patch Changes + +- 60d0a1a2e: Adds extraColumns field to GitHub Deployments card +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.1.2 ### Patch Changes diff --git a/plugins/github-deployments/README.md b/plugins/github-deployments/README.md index 174d1bc8c7..1bb8846105 100644 --- a/plugins/github-deployments/README.md +++ b/plugins/github-deployments/README.md @@ -18,15 +18,7 @@ The GitHub Deployments Plugin displays recent deployments from GitHub. yarn add @backstage/plugin-github-deployments ``` -2. Add the plugin to the app - -```typescript -// packages/app/src/plugins.ts - -export { githubDeploymentsPlugin as GithubDeploymentsPlugin } from '@backstage/plugin-github-deployments'; -``` - -3. Add the `EntityGithubDeploymentsCard` to the EntityPage: +2. Add the `EntityGithubDeploymentsCard` to the EntityPage: ```typescript // packages/app/src/components/catalog/EntityPage.tsx @@ -44,7 +36,7 @@ const OverviewContent = () => ( ); ``` -4. Add the `github.com/project-slug` annotation to your `catalog-info.yaml` file: +3. Add the `github.com/project-slug` annotation to your `catalog-info.yaml` file: ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index c5571b6e1f..049e02de4c 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-deployments", - "version": "0.1.2", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index d3a0d0df66..d1897b018c 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index 67e39d6600..681005b13f 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-graphiql +## 0.2.10 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.9 ### Patch Changes diff --git a/plugins/graphiql/README.md b/plugins/graphiql/README.md index c75d8b71e3..39372890d1 100644 --- a/plugins/graphiql/README.md +++ b/plugins/graphiql/README.md @@ -16,18 +16,13 @@ cd packages/app yarn add @backstage/plugin-graphiql ``` -```diff -# in packages/app/src/plugins.ts -+export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; -``` - ```diff # in packages/app/src/App.tsx -+import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; ++import { GraphiQLPage } from '@backstage/plugin-graphiql'; - const AppRoutes = () => ( - -+ } /> + const routes = ( + ++ } /> ``` ### Adding GraphQL endpoints diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 4ebedffd74..9ce1603b57 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.9", + "version": "0.2.10", "private": false, "publishConfig": { "access": "public", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index ab57d71f61..ac734e9a95 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-jenkins +## 0.4.2 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.4.1 ### Patch Changes diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index b3bc21b7d5..f406393190 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -14,11 +14,22 @@ Website: [https://jenkins.io/](https://jenkins.io/) yarn add @backstage/plugin-jenkins ``` -2. Add plugin: +2. Add the `EntityJenkinsContent` extension to the entity page in the app: -```js -// packages/app/src/plugins.ts -export { plugin as Jenkins } from '@backstage/plugin-jenkins'; +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EntityJenkinsContent } from '@backstage/plugin-circleci'; + +// ... +const serviceEntityPage = ( + + ... + + + + ... + +); ``` 3. Add proxy configuration to `app-config.yaml` diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c736eb0b5b..d1cabd2143 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.4.1", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 644c074b81..0963d3178f 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 379f0dd872..8bcac8a60b 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes-backend +## 0.3.5 + +### Patch Changes + +- c42cd1daa: Kubernetes client TLS verification is now configurable and defaults to true +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 + ## 0.3.4 ### Patch Changes diff --git a/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml b/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml index a944232224..059b719563 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml @@ -10,4 +10,4 @@ metadata: spec: type: service lifecycle: production - owner: guest + owner: user:guest diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 79864b7b48..7a8b16bc8a 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@google-cloud/container": "^2.2.0", @@ -52,7 +52,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@types/aws4": "^1.5.1", "supertest": "^6.1.3" }, diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index df00c0801f..8d21c79a24 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -33,10 +33,10 @@ "dependencies": { "@backstage/catalog-model": "^0.7.4", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/plugin-kubernetes-backend": "^0.3.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@kubernetes/client-node": "^0.14.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 26eb041442..cb2cb5b9d1 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-lighthouse +## 0.2.15 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.14 ### Patch Changes diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index 149172b7e8..028547ea3e 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -31,26 +31,17 @@ When you have an instance running that Backstage can hook into, first install th $ yarn add @backstage/plugin-lighthouse ``` -Then make sure to export the plugin in -your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) -to enable the plugin: - -```js -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -``` - -Modify your app routes in `App.tsx` to include the Router component exported from the plugin, for example: +Modify your app routes in `App.tsx` to include the `LighthousePage` component exported from the plugin, for example: ```tsx // At the top imports -import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; +import { LighthousePage } from '@backstage/plugin-lighthouse'; -// Inside App component - + // ... - } /> + } /> // ... -; +; ``` Then configure the `lighthouse-audit-service` URL in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index eb7d69d77d..2fce507756 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.14", + "version": "0.2.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,9 +33,9 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/lighthouse/src/components/Intro/index.tsx b/plugins/lighthouse/src/components/Intro/index.tsx index 25ffccabbd..a89518a5ed 100644 --- a/plugins/lighthouse/src/components/Intro/index.tsx +++ b/plugins/lighthouse/src/components/Intro/index.tsx @@ -49,19 +49,12 @@ When you have an instance running that Backstage can hook into, first install th $ yarn add @backstage/plugin-lighthouse \`\`\` -Then make sure to export the plugin in your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: +Modify your app routes in \`App.tsx\` to include the \`LighthousePage\` component exported from the plugin, for example: -\`\`\`js -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -\`\`\` - -Modify your app routes in \`App.tsx\` or \`App.jsx\` to include the Router component exported from the plugin, for example: - -\`\`\`js +\`\`\`tsx // At the top imports import { LighthousePage } from '@backstage/plugin-lighthouse'; -// Inside App component // ... } /> diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 82952c7452..2dbb2a7eb8 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/package.json b/plugins/org/package.json index 0a9917cb7d..9ad4704630 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -21,10 +21,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/core-api": "^0.2.16", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,7 +35,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index 0cd9d0cfb8..b7fc7fec40 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-pagerduty +## 0.3.3 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.2 ### Patch Changes diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index 9d8023b9fe..36f380fdd5 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -22,12 +22,6 @@ Install the plugin: yarn add @backstage/plugin-pagerduty ``` -Add it to the app in `plugins.ts`: - -```ts -export { plugin as Pagerduty } from '@backstage/plugin-pagerduty'; -``` - Add it to the `EntityPage.tsx`: ```ts diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 44e8cb5b7f..821b89a5b1 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-pagerduty", - "version": "0.3.2", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 6c47043602..571de96ac4 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -59,8 +59,8 @@ describe('buildMiddleware', () => { mockCreateProxyMiddleware.mockClear(); }); - it('accepts strings', async () => { - buildMiddleware('/api/', logger, 'test', 'http://mocked'); + it('accepts strings prefixed by /', async () => { + buildMiddleware('/proxy', logger, '/test', 'http://mocked'); expect(createProxyMiddleware).toHaveBeenCalledTimes(1); @@ -74,13 +74,53 @@ describe('buildMiddleware', () => { expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); - expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' }); + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('accepts routes not prefixed with / when path is not suffixed with /', async () => { + buildMiddleware('/proxy', logger, 'test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ProxyMiddlewareConfig, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('accepts routes prefixed with / when path is suffixed with /', async () => { + buildMiddleware('/proxy/', logger, '/test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ProxyMiddlewareConfig, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); expect(fullConfig.changeOrigin).toBe(true); expect(fullConfig.logProvider!(logger)).toBe(logger); }); it('limits allowedMethods', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', allowedMethods: ['GET', 'DELETE'], }); @@ -97,13 +137,13 @@ describe('buildMiddleware', () => { expect(filter('', { method: 'PATCH', headers: {} })).toBe(false); expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); - expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' }); + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); expect(fullConfig.changeOrigin).toBe(true); expect(fullConfig.logProvider!(logger)).toBe(logger); }); it('permits default headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', }); @@ -143,7 +183,7 @@ describe('buildMiddleware', () => { }); it('permits default and configured headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', headers: { Authorization: 'my-token', @@ -176,7 +216,7 @@ describe('buildMiddleware', () => { }); it('permits configured headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', allowedHeaders: ['authorization', 'cookie'], }); @@ -208,7 +248,7 @@ describe('buildMiddleware', () => { }); it('responds default headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', }); @@ -251,7 +291,7 @@ describe('buildMiddleware', () => { }); it('responds configured headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', allowedHeaders: ['set-cookie'], }); @@ -282,10 +322,10 @@ describe('buildMiddleware', () => { it('rejects malformed target URLs', async () => { expect(() => - buildMiddleware('/api/', logger, 'test', 'backstage.io'), + buildMiddleware('/proxy', logger, '/test', 'backstage.io'), ).toThrowError(/Proxy target is not a valid URL/); expect(() => - buildMiddleware('/api/', logger, 'test', { target: 'backstage.io' }), + buildMiddleware('/proxy', logger, '/test', { target: 'backstage.io' }), ).toThrowError(/Proxy target is not a valid URL/); }); }); diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 5c306de966..71ed82c23b 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -77,10 +77,24 @@ export function buildMiddleware( `Proxy target is not a valid URL: ${fullConfig.target ?? ''}`, ); } + // Default is to do a path rewrite that strips out the proxy's path prefix // and the rest of the route. if (fullConfig.pathRewrite === undefined) { - const routeWithSlash = route.endsWith('/') ? route : `${route}/`; + let routeWithSlash = route.endsWith('/') ? route : `${route}/`; + + if (!pathPrefix.endsWith('/') && !routeWithSlash.startsWith('/')) { + // Need to insert a / between pathPrefix and routeWithSlash + routeWithSlash = `/${routeWithSlash}`; + } else if (pathPrefix.endsWith('/') && routeWithSlash.startsWith('/')) { + // Never expect this to happen at this point in time as + // pathPrefix is set using `getExternalBaseUrl` which "Returns the + // external HTTP base backend URL for a given plugin, + // **without a trailing slash.**". But in case this changes in future, we + // need to drop a / on either pathPrefix or routeWithSlash + routeWithSlash = routeWithSlash.substring(1); + } + fullConfig.pathRewrite = { [`^${pathPrefix}${routeWithSlash}`]: '/', }; diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index 424887263c..3634972e9a 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-register-component +## 0.2.14 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.13 ### Patch Changes diff --git a/plugins/register-component/README.md b/plugins/register-component/README.md index 0abf27590f..9c5304385e 100644 --- a/plugins/register-component/README.md +++ b/plugins/register-component/README.md @@ -16,69 +16,24 @@ When installed it is accessible on [localhost:3000/register-component](localhost ## Standalone setup -0. Install plugin and its dependency `plugin-catalog` +1. Install plugin and its dependency `plugin-catalog` ```bash -yarn add @backstage/plugin-register-component -W -yarn add @backstage/plugin-catalog -W +cd packages/app +yarn add @backstage/plugin-register-component ``` -1. Add dependencies to the active plugins list +2. Add the `RegisterComponentPage` extension to your `App.tsx`: -```typescript -// packages/app/src/plugins.ts -export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; -export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; -``` - -2. Create `packages/app/src/apis.ts` and register all the needed plugins - -```typescript -import { - alertApiRef, - AlertApiForwarder, - ApiRegistry, - ConfigApi, - errorApiRef, - ErrorApiForwarder, - ErrorAlerter, -} from '@backstage/core'; - -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; - -export const apis = (config: ConfigApi) => { - const backendUrl = config.getString('backend.baseUrl'); - - const builder = ApiRegistry.builder(); - - const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); - const errorApi = builder.add( - errorApiRef, - new ErrorAlerter(alertApi, new ErrorApiForwarder()), - ); - - builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: backendUrl, - basePath: '/catalog', - }), - ); - - return builder.build(); -}; -``` - -3. Pass `apis` to createApp - -```typescript +```tsx // packages/app/src/App.tsx -import { apis } from './apis'; +import { RegisterComponentPage } from '@backstage/plugin-cost-insights'; -const app = createApp({ - apis, - plugins: Object.values(plugins), -}); + + ... + } /> + ... +; ``` ## Running diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index a8cfcc894a..38d1e3202e 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index a64fa25a81..8e20e93555 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-rollbar-backend +## 0.1.9 + +### Patch Changes + +- 49574a8a3: Fix some `spleling`. + + The `scaffolder-backend` has a configuration schema change that may be breaking + in rare circumstances. Due to a typo in the schema, the + `scaffolder.github.visibility`, `scaffolder.gitlab.visibility`, and + `scaffolder.bitbucket.visibility` did not get proper validation that the value + is one of the supported strings (`public`, `internal` (not available for + Bitbucket), and `private`). If you had a value that was not one of these three, + you may have to adjust your config. + +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 + ## 0.1.8 ### Patch Changes diff --git a/plugins/rollbar-backend/config.d.ts b/plugins/rollbar-backend/config.d.ts index 473b27a506..bb48f38cf2 100644 --- a/plugins/rollbar-backend/config.d.ts +++ b/plugins/rollbar-backend/config.d.ts @@ -18,7 +18,7 @@ export interface Config { /** Configuration options for the rollbar-backend plugin */ rollbar?: { /** - * The autentication token for accessing the Rollbar API + * The authentication token for accessing the Rollbar API * @see https://explorer.docs.rollbar.com/#section/Authentication */ accountToken: string; diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 489c12139d..05b02b2c39 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", + "@backstage/backend-common": "^0.6.3", "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@backstage/cli": "^0.6.9", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index 2e9bc946a2..1d8c2c399e 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-rollbar +## 0.3.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.3 ### Patch Changes diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index 993b93d3a0..3a33fe223c 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -12,33 +12,25 @@ Website: [https://rollbar.com/](https://rollbar.com/) yarn add @backstage/plugin-rollbar ``` -3. Add plugin to the list of plugins: +3. Add to the app `EntityPage` component: -```ts -// packages/app/src/plugins.ts -export { plugin as Rollbar } from '@backstage/plugin-rollbar'; -``` - -4. Add to the app `EntityPage` component: - -```ts +```tsx // packages/app/src/components/catalog/EntityPage.tsx -import { Router as RollbarRouter } from '@backstage/plugin-rollbar'; +import { EntityRollbarContent } from '@backstage/plugin-rollbar'; // ... -const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( +const serviceEntityPage = ( - // ... - } - /> + ... + + + + ... ); ``` -5. Setup the `app.config.yaml` and account token environment variable +4. Setup the `app-config.yaml` and account token environment variable ```yaml # app.config.yaml @@ -48,7 +40,7 @@ rollbar: accountToken: ${ROLLBAR_ACCOUNT_TOKEN} ``` -6. Annotate entities with the rollbar project slug +5. Annotate entities with the rollbar project slug ```yaml # pump-station-catalog-component.yaml @@ -60,7 +52,7 @@ metadata: rollbar.com/project-slug: project-name ``` -7. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity +6. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity ## Features diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 6d0e49b1ae..64ea010b54 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 2c45bdc611..f591568d44 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-scaffolder-backend +## 0.10.1 + +### Patch Changes + +- a1783f306: Added the `nebula-preview` preview to `Octokit` for repository visibility. + +## 0.10.0 + +### Minor Changes + +- 49574a8a3: Fix some `spleling`. + + The `scaffolder-backend` has a configuration schema change that may be breaking + in rare circumstances. Due to a typo in the schema, the + `scaffolder.github.visibility`, `scaffolder.gitlab.visibility`, and + `scaffolder.bitbucket.visibility` did not get proper validation that the value + is one of the supported strings (`public`, `internal` (not available for + Bitbucket), and `private`). If you had a value that was not one of these three, + you may have to adjust your config. + +### Patch Changes + +- 84c54474d: Forward user token to scaffolder task for subsequent api requests +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 + ## 0.9.6 ### Patch Changes diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 3b31b4f951..aefa6154ca 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -22,14 +22,14 @@ export interface Config { /** * The visibility to set on created repositories. */ - visiblity?: 'public' | 'internal' | 'private'; + visibility?: 'public' | 'internal' | 'private'; }; gitlab?: { api: { [key: string]: string }; /** * The visibility to set on created repositories. */ - visiblity?: 'public' | 'internal' | 'private'; + visibility?: 'public' | 'internal' | 'private'; }; azure?: { baseUrl: string; @@ -40,7 +40,7 @@ export interface Config { /** * The visibility to set on created repositories. */ - visiblity?: 'public' | 'private'; + visibility?: 'public' | 'private'; }; }; } diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 2c65d91604..355bed4de0 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.9.6", + "version": "0.10.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.2", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", @@ -67,7 +67,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", diff --git a/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml index dd1e0ebd09..04f055bd46 100644 --- a/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml @@ -5,4 +5,4 @@ metadata: spec: type: website lifecycle: experimental - owner: guest + owner: user:guest diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index e1a064b3e6..36f5341bbb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -50,7 +50,7 @@ export function createPublishAzureAction(options: { }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index 13f639d745..243e515b19 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -184,13 +184,13 @@ export function createPublishBitbucketAction(options: { type: 'string', }, repoVisibility: { - title: 'Repository Visiblity', + title: 'Repository Visibility', type: 'string', enum: ['private', 'public'], }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index e58f2c68aa..66dfc1d655 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -67,13 +67,13 @@ export function createPublishGithubAction(options: { type: 'string', }, repoVisibility: { - title: 'Repository Visiblity', + title: 'Repository Visibility', type: 'string', enum: ['private', 'public', 'internal'], }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, collaborators: { @@ -149,6 +149,7 @@ export function createPublishGithubAction(options: { const client = new Octokit({ auth: token, baseUrl: integrationConfig.config.apiBaseUrl, + previews: ['nebula-preview'], }); const user = await client.users.getByUsername({ @@ -180,7 +181,7 @@ export function createPublishGithubAction(options: { repo, permission: 'admin', }); - // no need to add access if it's the person who own's the personal account + // No need to add access if it's the person who owns the personal account } else if (access && access !== owner) { await client.repos.addCollaborator({ owner, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 37074d6f57..0605f88983 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -44,13 +44,13 @@ export function createPublishGitlabAction(options: { type: 'string', }, repoVisibility: { - title: 'Repository Visiblity', + title: 'Repository Visibility', type: 'string', enum: ['private', 'public', 'internal'], }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index d0bfc89529..829856ec0c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -72,6 +72,7 @@ export class GithubPublisher implements PublisherBase { const client = new Octokit({ auth: token, baseUrl: this.config.apiBaseUrl, + previews: ['nebula-preview'], }); const description = values.description as string; diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index b7a8cbb89c..1d0bbcede9 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,34 @@ # @backstage/plugin-scaffolder +## 0.9.2 + +### Patch Changes + +- f6efa71ee: Enable starred templates on Scaffolder frontend +- 19a4dd710: Removed unused `swr` dependency. +- 23769512a: Support `anyOf`, `oneOf` and `allOf` schemas in the scaffolder template. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + +## 0.9.1 + +### Patch Changes + +- 99fbef232: Adding Headings for Accessibility on the Scaffolder Plugin +- cb0206b2b: Respect top-level UI schema keys in scaffolder forms. Allows more advanced RJSF features such as explicit field ordering. +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.9.0 ### Minor Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 10785a4db7..32229ccbc6 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.0", + "version": "0.9.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,18 +33,18 @@ "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "git-url-parse": "^11.4.4", "humanize-duration": "^3.25.1", "immer": "^9.0.1", @@ -55,12 +55,11 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.3.0", "use-immer": "^0.5.1", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx new file mode 100644 index 0000000000..ae6057beff --- /dev/null +++ b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps, useMemo } from 'react'; +import { useStarredEntities } from '@backstage/plugin-catalog-react'; +import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core'; +import StarBorder from '@material-ui/icons/StarBorder'; +import Star from '@material-ui/icons/Star'; +import { Entity } from '@backstage/catalog-model'; + +type Props = ComponentProps & { entity: Entity }; + +const YellowStar = withStyles({ + root: { + color: '#f3ba37', + }, +})(Star); + +const WhiteBorderStar = withStyles({ + root: { + color: '#ffffff', + }, +})(StarBorder); + +const useStyles = makeStyles(theme => ({ + starButton: { + position: 'absolute', + top: theme.spacing(0.5), + right: theme.spacing(0.5), + padding: '0.25rem', + }, +})); + +export const favouriteTemplateTooltip = (isStarred: boolean) => + isStarred ? 'Remove from favorites' : 'Add to favorites'; + +export const favouriteTemplateIcon = (isStarred: boolean) => + isStarred ? : ; + +/** + * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities + * @param props MaterialUI IconButton props extended by required `entity` prop + */ +export const FavouriteTemplate = (props: Props) => { + const classes = useStyles(); + const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); + const isStarred = useMemo(() => isStarredEntity(props.entity), [ + isStarredEntity, + props.entity, + ]); + return ( + toggleStarredEntity(props.entity)} + > + + {favouriteTemplateIcon(isStarred)} + + + ); +}; diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts index b83725c2bb..64dd705952 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts @@ -70,4 +70,220 @@ describe('transformSchemaToProps', () => { uiSchema: expectedUiSchema, }); }); + + it('transforms schema with anyOf fields', () => { + const inputSchema = { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + 'ui:readonly': true, + }, + }, + }, + ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], + properties: { + field1: { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + 'ui:readonly': true, + }, + }, + }, + ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], + }, + field2: { + type: 'string', + 'ui:derp': 'xerp', + }, + }, + }; + const expectedSchema = { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + }, + }, + }, + ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], + properties: { + field1: { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + }, + }, + }, + ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], + }, + field2: { + type: 'string', + }, + }, + }; + const expectedUiSchema = { + field3: { + 'ui:readonly': true, + }, + field4: { + 'ui:readonly': true, + }, + field5: { + 'ui:readonly': true, + }, + field1: { + field3: { + 'ui:readonly': true, + }, + field4: { + 'ui:readonly': true, + }, + field5: { + 'ui:readonly': true, + }, + }, + field2: { + 'ui:derp': 'xerp', + }, + }; + + expect(transformSchemaToProps(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); }); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts index e591589bd8..b4aa3e309d 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -26,7 +26,7 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { return; } - const { properties } = schema; + const { properties, anyOf, oneOf, allOf } = schema; for (const propName in schema) { if (!schema.hasOwnProperty(propName)) { @@ -39,22 +39,47 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { } } - if (!isObject(properties)) { - return; + if (isObject(properties)) { + for (const propName in properties) { + if (!properties.hasOwnProperty(propName)) { + continue; + } + + const schemaNode = properties[propName]; + if (!isObject(schemaNode)) { + continue; + } + const innerUiSchema = {}; + uiSchema[propName] = innerUiSchema; + extractUiSchema(schemaNode, innerUiSchema); + } } - for (const propName in properties) { - if (!properties.hasOwnProperty(propName)) { - continue; + if (Array.isArray(anyOf)) { + for (const schemaNode of anyOf) { + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); } + } - const schemaNode = properties[propName]; - if (!isObject(schemaNode)) { - continue; + if (Array.isArray(oneOf)) { + for (const schemaNode of oneOf) { + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); + } + } + + if (Array.isArray(allOf)) { + for (const schemaNode of allOf) { + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); } - const innerUiSchema = {}; - uiSchema[propName] = innerUiSchema; - extractUiSchema(schemaNode, innerUiSchema); } } diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 68bf588d01..4a482402f8 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -39,7 +39,7 @@ import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import { ScaffolderFilter } from '../ScaffolderFilter'; import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter'; import SearchToolbar from '../SearchToolbar/SearchToolbar'; -import { TemplateCard, TemplateCardProps } from '../TemplateCard'; +import { TemplateCard } from '../TemplateCard'; import { registerComponentRouteRef } from '../../routes'; const useStyles = makeStyles(theme => ({ @@ -51,19 +51,6 @@ const useStyles = makeStyles(theme => ({ }, })); -const getTemplateCardProps = ( - template: TemplateEntityV1alpha1, -): TemplateCardProps & { key: string } => { - return { - key: template.metadata.uid!, - name: template.metadata.name, - title: `${(template.metadata.title || template.metadata.name) ?? ''}`, - type: template.spec.type ?? '', - description: template.metadata.description ?? '-', - tags: (template.metadata?.tags as string[]) ?? [], - }; -}; - export const ScaffolderPageContents = () => { const styles = useStyles(); const { @@ -188,7 +175,7 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && matchingEntities.map(template => ( - + ))} diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 2ba83fdaae..8c64d981ad 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -28,8 +28,13 @@ import { import React from 'react'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; const useStyles = makeStyles({ + cardHeader: { + position: 'relative', + }, title: { backgroundImage: ({ backgroundImage }: any) => backgroundImage, }, @@ -43,6 +48,10 @@ const useStyles = makeStyles({ }); export type TemplateCardProps = { + template: TemplateEntityV1alpha1; +}; + +type TemplateProps = { description: string; tags: string[]; title: string; @@ -50,42 +59,55 @@ export type TemplateCardProps = { name: string; }; -export const TemplateCard = ({ - description, - tags, - title, - type, - name, -}: TemplateCardProps) => { +const getTemplateCardProps = ( + template: TemplateEntityV1alpha1, +): TemplateProps & { key: string } => { + return { + key: template.metadata.uid!, + name: template.metadata.name, + title: `${(template.metadata.title || template.metadata.name) ?? ''}`, + type: template.spec.type ?? '', + description: template.metadata.description ?? '-', + tags: (template.metadata?.tags as string[]) ?? [], + }; +}; + +export const TemplateCard = ({ template }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); + const templateProps = getTemplateCardProps(template); - const themeId = pageTheme[type] ? type : 'other'; + const themeId = pageTheme[templateProps.type] ? templateProps.type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); const href = generatePath(`${rootLink()}/templates/:templateName`, { - templateName: name, + templateName: templateProps.name, }); return ( - + + - {tags?.map(tag => ( + {templateProps.tags?.map(tag => ( ))} - {description} + {templateProps.description} - diff --git a/plugins/search-backend-node/CHANGELOG.md b/plugins/search-backend-node/CHANGELOG.md index 8718488b21..da49bd0726 100644 --- a/plugins/search-backend-node/CHANGELOG.md +++ b/plugins/search-backend-node/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-search-backend-node +## 0.1.4 + +### Patch Changes + +- e1e757569: Introduced Scheduler which is responsible for adding new tasks to a schedule together with it's interval timer as well as starting and stopping the scheduler processes. + +## 0.1.3 + +### Patch Changes + +- b9b2b4b76: Lunr Search Engine support + ## 0.1.2 ### Patch Changes diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index ab260ada5b..e39fbb5579 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend-node", - "version": "0.1.2", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,11 +20,13 @@ }, "dependencies": { "@backstage/search-common": "^0.1.1", - "winston": "^3.2.1" + "winston": "^3.2.1", + "lunr": "^2.3.9", + "@types/lunr": "^2.3.3" }, "devDependencies": { - "@backstage/backend-common": "^0.6.0", - "@backstage/cli": "^0.6.6" + "@backstage/backend-common": "^0.6.3", + "@backstage/cli": "^0.6.9" }, "files": [ "dist" diff --git a/plugins/search-backend-node/src/index.test.ts b/plugins/search-backend-node/src/IndexBuilder.test.ts similarity index 75% rename from plugins/search-backend-node/src/index.test.ts rename to plugins/search-backend-node/src/IndexBuilder.test.ts index 0941f23540..e1dc511b97 100644 --- a/plugins/search-backend-node/src/index.test.ts +++ b/plugins/search-backend-node/src/IndexBuilder.test.ts @@ -21,6 +21,7 @@ import { IndexableDocument, } from '@backstage/search-common'; import { IndexBuilder } from './IndexBuilder'; +import { LunrSearchEngine, SearchEngine } from './index'; class TestDocumentCollator implements DocumentCollator { async execute() { @@ -35,41 +36,51 @@ class TestDocumentDecorator implements DocumentDecorator { } describe('IndexBuilder', () => { + let testSearchEngine: SearchEngine; let testIndexBuilder: IndexBuilder; let testCollator: DocumentCollator; let testDecorator: DocumentDecorator; beforeEach(() => { - testIndexBuilder = new IndexBuilder({ logger: getVoidLogger() }); + const logger = getVoidLogger(); + testSearchEngine = new LunrSearchEngine({ logger }); + testIndexBuilder = new IndexBuilder({ + logger, + searchEngine: testSearchEngine, + }); testCollator = new TestDocumentCollator(); testDecorator = new TestDocumentDecorator(); }); describe('addCollator', () => { it('adds a collator', async () => { + jest.useFakeTimers(); const collatorSpy = jest.spyOn(testCollator, 'execute'); // Add a collator. testIndexBuilder.addCollator({ type: 'anything', - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); // Build the index and ensure the collator was invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); + jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); }); }); describe('addDecorator', () => { it('adds a decorator', async () => { + jest.useFakeTimers(); const decoratorSpy = jest.spyOn(testDecorator, 'execute'); // Add a collator. testIndexBuilder.addCollator({ type: 'anything', - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); @@ -79,11 +90,16 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the decorator was invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); + jest.advanceTimersByTime(6000); + // wait for async decorator execution + await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); }); it('adds a type-specific decorator', async () => { + jest.useFakeTimers(); const expectedType = 'an-expected-type'; const docFixture = { title: 'Test', @@ -98,7 +114,7 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ type: expectedType, - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); @@ -109,7 +125,11 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the decorator was invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); + jest.advanceTimersByTime(6000); + // wait for async decorator execution + await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); expect(decoratorSpy).toHaveBeenCalledWith([docFixture]); }); @@ -121,7 +141,7 @@ describe('IndexBuilder', () => { text: 'Test text.', location: '/test/location', }; - jest + const collatorSpy = jest .spyOn(testCollator, 'execute') .mockImplementation(async () => [docFixture]); const decoratorSpy = jest.spyOn(testDecorator, 'execute'); @@ -129,7 +149,7 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ type: expectedType, - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); @@ -140,7 +160,10 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the decorator was not invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); + jest.advanceTimersByTime(6000); + expect(collatorSpy).toHaveBeenCalled(); expect(decoratorSpy).not.toHaveBeenCalled(); }); }); diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 43bf84a59d..71d374e704 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -16,9 +16,11 @@ import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; import { Logger } from 'winston'; +import { Scheduler } from './index'; import { RegisterCollatorParameters, RegisterDecoratorParameters, + SearchEngine, } from './types'; interface CollatorEnvelope { @@ -27,18 +29,25 @@ interface CollatorEnvelope { } type IndexBuilderOptions = { + searchEngine: SearchEngine; logger: Logger; }; export class IndexBuilder { private collators: Record; private decorators: Record; + private searchEngine: SearchEngine; private logger: Logger; - constructor({ logger }: IndexBuilderOptions) { + constructor({ logger, searchEngine }: IndexBuilderOptions) { this.collators = {}; this.decorators = {}; this.logger = logger; + this.searchEngine = searchEngine; + } + + getSearchEngine(): SearchEngine { + return this.searchEngine; } /** @@ -83,31 +92,42 @@ export class IndexBuilder { } /** - * Starts the process of executing collators and decorators and building the - * search index. - * - * TODO: But like with coordination, timing, error handling, and what have you. + * Compiles collators and decorators into tasks, which are added to a + * scheduler returned to the caller. */ - async build() { - return Promise.all( - Object.keys(this.collators).map(async type => { + async build(): Promise<{ scheduler: Scheduler }> { + const scheduler = new Scheduler({ logger: this.logger }); + + Object.keys(this.collators).forEach(type => { + scheduler.addToSchedule(async () => { + // Collate, Decorate, Index. const decorators: DocumentDecorator[] = ( this.decorators['*'] || [] ).concat(this.decorators[type] || []); - this.logger.info( + this.logger.debug( `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, ); let documents = await this.collators[type].collate.execute(); for (let i = 0; i < decorators.length; i++) { - this.logger.info( + this.logger.debug( `Decorating ${type} documents via ${decorators[i].constructor.name}`, ); documents = await decorators[i].execute(documents); } - // TODO: push documents to a configured search engine. - }), - ); + if (!documents || documents.length === 0) { + this.logger.debug(`No documents for type "${type}" to index`); + return; + } + + // pushing documents to index to a configured search engine. + this.searchEngine.index(type, documents); + }, this.collators[type].refreshInterval * 1000); + }); + + return { + scheduler, + }; } } diff --git a/plugins/search-backend-node/src/Scheduler.test.ts b/plugins/search-backend-node/src/Scheduler.test.ts new file mode 100644 index 0000000000..43fdb7ea92 --- /dev/null +++ b/plugins/search-backend-node/src/Scheduler.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { Scheduler } from './index'; + +describe('Scheduler', () => { + let testScheduler: Scheduler; + + beforeEach(() => { + const logger = getVoidLogger(); + testScheduler = new Scheduler({ + logger, + }); + }); + + describe('addToSchedule', () => { + it('should not add a task and interval to schedule, if already started', async () => { + jest.useFakeTimers(); + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + + // Add a task and interval to schedule + testScheduler.addToSchedule(mockTask1, 2); + + // Starts scheduling process + testScheduler.start(); + + // Throws Error if task and interval is added to a already started schedule + expect(() => testScheduler.addToSchedule(mockTask2, 2)).toThrowError(); + + jest.runOnlyPendingTimers(); + expect(mockTask1).toHaveBeenCalled(); + expect(mockTask2).not.toHaveBeenCalled(); + }); + + it('should be possible to add a task and interval to schedule, if already started, but stopped in between', async () => { + jest.useFakeTimers(); + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + + // Add a task and interval to schedule + testScheduler.addToSchedule(mockTask1, 2); + + // Starts scheduling process + testScheduler.start(); + + // Stop scheduling process + testScheduler.stop(); + + // Should't throw error, as it is stopped. + expect(() => + testScheduler.addToSchedule(mockTask2, 4), + ).not.toThrowError(); + + // Starts scheduling process + testScheduler.start(); + + jest.runOnlyPendingTimers(); + expect(mockTask1).toHaveBeenCalled(); + expect(mockTask2).toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/search-backend-node/src/Scheduler.ts b/plugins/search-backend-node/src/Scheduler.ts new file mode 100644 index 0000000000..e54a837cee --- /dev/null +++ b/plugins/search-backend-node/src/Scheduler.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Logger } from 'winston'; + +type TaskEnvelope = { + task: Function; + interval: number; +}; + +/** + * TODO: coordination, error handling + */ + +export class Scheduler { + private logger: Logger; + private schedule: TaskEnvelope[]; + private intervalTimeouts: NodeJS.Timeout[] = []; + + constructor({ logger }: { logger: Logger }) { + this.logger = logger; + this.schedule = []; + } + + /** + * Adds each task and interval to the schedule + * + */ + addToSchedule(task: Function, interval: number) { + if (this.intervalTimeouts.length) { + throw new Error( + 'Cannot add task to schedule that has already been started.', + ); + } + this.schedule.push({ task, interval }); + } + + /** + * Starts the scheduling process for each task + */ + start() { + this.logger.info('Starting all scheduled search tasks.'); + this.schedule.forEach(({ task, interval }) => { + this.intervalTimeouts.push( + setInterval(() => { + task(); + }, interval), + ); + }); + } + + /** + * Stop all scheduled tasks. + */ + stop() { + this.logger.info('Stopping all scheduled search tasks.'); + this.intervalTimeouts.forEach(timeout => { + clearInterval(timeout); + }); + this.intervalTimeouts = []; + } +} diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts new file mode 100644 index 0000000000..6c348235e0 --- /dev/null +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts @@ -0,0 +1,325 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { LunrSearchEngine } from './LunrSearchEngine'; +import { SearchEngine } from '../types'; + +describe('LunrSearchEngine', () => { + let testLunrSearchEngine: SearchEngine; + + beforeEach(() => { + testLunrSearchEngine = new LunrSearchEngine({ logger: getVoidLogger() }); + }); + + describe('translator', () => { + it('query translator invoked', async () => { + const translatorSpy = jest.spyOn(testLunrSearchEngine, 'translator'); + + // Translate query and ensure the translator was invoked. + await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + expect(translatorSpy).toHaveBeenCalled(); + expect(translatorSpy).toHaveBeenCalledWith({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + }); + + it('should return translated query', async () => { + const mockedTranslatedQuery = await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + expect(mockedTranslatedQuery).toMatchObject({ + documentTypes: ['*'], + lunrQueryString: 'testTerm', + }); + }); + + it('should return translated query with 1 filter', async () => { + const mockedTranslatedQuery = await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: { kind: 'testKind' }, + pageCursor: '', + }); + + expect(mockedTranslatedQuery).toMatchObject({ + documentTypes: ['*'], + lunrQueryString: 'testTerm +kind:testKind', + }); + }); + + it('should return translated query with multiple filters', async () => { + const mockedTranslatedQuery = await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: { kind: 'testKind', namespace: 'testNameSpace' }, + pageCursor: '', + }); + + expect(mockedTranslatedQuery).toMatchObject({ + documentTypes: ['*'], + lunrQueryString: 'testTerm +kind:testKind +namespace:testNameSpace', + }); + }); + }); + + describe('query', () => { + it('should perform search query', async () => { + const querySpy = jest.spyOn(testLunrSearchEngine, 'query'); + + // Perform search query and ensure the query func was invoked. + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + expect(querySpy).toHaveBeenCalled(); + expect(querySpy).toHaveBeenCalledWith({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + // Should return 0 results as nothing is indexed here + expect(mockedSearchResult).toMatchObject({ results: [] }); + }); + + it('should perform search query and return 0 results on no match', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + ]; + + // Mock indexing of 1 document + testLunrSearchEngine.index('test-index', mockDocuments); + + // Perform search query + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + // Should return 0 results as we are mocking the indexing of 1 document but with no match on the fields + expect(mockedSearchResult).toMatchObject({ results: [] }); + }); + + it('should perform search query and return search results on match', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + ]; + + // Mock indexing of 1 document + testLunrSearchEngine.index('test-index', mockDocuments); + + // Perform search query + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + filters: {}, + pageCursor: '', + }); + + // Should return 1 result as we are mocking the indexing of 1 document with match on the title field + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + }, + ], + }); + }); + + it('should perform search query and return search results on match with filters', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + }, + ]; + + // Mock indexing of 2 documents + testLunrSearchEngine.index('test-index', mockDocuments); + + // Perform search query + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + filters: { location: 'test/location2' }, + pageCursor: '', + }); + + // Should return 1 of 2 results as we are + // 1. Mocking the indexing of 2 documents + // 2. Matching on the location field with the filter { location: 'test/location2' } + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + }, + }, + ], + }); + }); + + it('should perform search query and return search results on match with filter and not fail on missing field', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + ]; + + const mockDocuments2 = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + extraField: 'testExtraField', + }, + ]; + + // Mock 2 indices with 1 document each + testLunrSearchEngine.index('test-index', mockDocuments); + testLunrSearchEngine.index('test-index-2', mockDocuments2); + // Perform search query scoped to "test-index-2" with a filter on the field "extraField" + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + pageCursor: '', + filters: { extraField: 'testExtraField' }, + }); + + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + extraField: 'testExtraField', + }, + }, + ], + }); + }); + + it('should perform search query and return search results on match, scoped to specific index', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + }, + ]; + + const mockDocuments2 = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location3', + }, + { + title: 'testTitle', + text: 'testText', + location: 'test/location4', + }, + ]; + + // Mock 2 indices with 2 documents each + testLunrSearchEngine.index('test-index', mockDocuments); + testLunrSearchEngine.index('test-index-2', mockDocuments2); + + // Perform search query scoped to "test-index-2" + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + types: ['test-index-2'], + pageCursor: '', + }); + + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + location: 'test/location3', + text: 'testText', + title: 'testTitle', + }, + }, + { + document: { + location: 'test/location4', + text: 'testText', + title: 'testTitle', + }, + }, + ], + }); + }); + }); + + describe('index', () => { + it('should index document', async () => { + const indexSpy = jest.spyOn(testLunrSearchEngine, 'index'); + const mockDocuments = [ + { + title: 'testTerm', + text: 'testText', + location: 'test/location', + }, + ]; + + // call index func and ensure the index func was invoked. + testLunrSearchEngine.index('test-index', mockDocuments); + expect(indexSpy).toHaveBeenCalled(); + expect(indexSpy).toHaveBeenCalledWith('test-index', [ + { title: 'testTerm', text: 'testText', location: 'test/location' }, + ]); + }); + }); +}); diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts new file mode 100644 index 0000000000..cc8c5bb982 --- /dev/null +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SearchQuery, + IndexableDocument, + SearchResultSet, +} from '@backstage/search-common'; +import lunr from 'lunr'; +import { Logger } from 'winston'; +import { SearchEngine, QueryTranslator } from '../types'; + +type ConcreteLunrQuery = { + lunrQueryString: string; + documentTypes: string[]; +}; + +export class LunrSearchEngine implements SearchEngine { + protected lunrIndices: Record = {}; + protected docStore: Record; + protected logger: Logger; + + constructor({ logger }: { logger: Logger }) { + this.logger = logger; + this.docStore = {}; + } + + translator: QueryTranslator = ({ + term, + filters, + types, + }: SearchQuery): ConcreteLunrQuery => { + let lunrQueryFilters; + if (filters) { + lunrQueryFilters = Object.entries(filters) + .map(([key, value]) => ` +${key}:${value}`) + .join(''); + } + + return { + lunrQueryString: `${term}${lunrQueryFilters || ''}`, + documentTypes: types || ['*'], + }; + }; + + index(type: string, documents: IndexableDocument[]): void { + const lunrBuilder = new lunr.Builder(); + // Make this lunr index aware of all relevant fields. + Object.keys(documents[0]).forEach(field => { + lunrBuilder.field(field); + }); + + // Set "location" field as reference field + lunrBuilder.ref('location'); + + documents.forEach((document: IndexableDocument) => { + // Add document to Lunar index + lunrBuilder.add(document); + // Store documents in memory to be able to look up document using the ref during query time + // This is not how you should implement your SearchEngine implementation! Do not copy! + this.docStore[document.location] = document; + }); + + // "Rotate" the index by simply overwriting any existing index of the same name. + this.lunrIndices[type] = lunrBuilder.build(); + } + + query(query: SearchQuery): Promise { + const { lunrQueryString, documentTypes } = this.translator( + query, + ) as ConcreteLunrQuery; + + const results: lunr.Index.Result[] = []; + + if (documentTypes.length === 1 && documentTypes[0] === '*') { + // Iterate over all this.lunrIndex values. + Object.values(this.lunrIndices).forEach(i => { + try { + results.push(...i.search(lunrQueryString)); + } catch (err) { + // if a field does not exist on a index, we can see that as a no-match + if ( + err instanceof lunr.QueryParseError && + err.message.startsWith('unrecognised field') + ) + return; + } + }); + } else { + // Iterate over the filtered list of this.lunrIndex keys. + Object.keys(this.lunrIndices) + .filter(d => documentTypes.includes(d)) + .forEach(d => { + try { + results.push(...this.lunrIndices[d].search(lunrQueryString)); + } catch (err) { + // if a field does not exist on a index, we can see that as a no-match + if ( + err instanceof lunr.QueryParseError && + err.message.startsWith('unrecognised field') + ) + return; + } + }); + } + + // Sort results. + results.sort((doc1, doc2) => { + return doc2.score - doc1.score; + }); + + // Translate results into SearchResultSet + const resultSet: SearchResultSet = { + results: results.map(d => { + return { document: this.docStore[d.ref] }; + }), + }; + + return Promise.resolve(resultSet); + } +} diff --git a/plugins/search-backend-node/src/engines/index.ts b/plugins/search-backend-node/src/engines/index.ts new file mode 100644 index 0000000000..ed7079ef62 --- /dev/null +++ b/plugins/search-backend-node/src/engines/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LunrSearchEngine } from './LunrSearchEngine'; diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index 924dfbc9dc..7ee75e3d59 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -15,3 +15,6 @@ */ export { IndexBuilder } from './IndexBuilder'; +export { Scheduler } from './Scheduler'; +export { LunrSearchEngine } from './engines'; +export type { SearchEngine } from './types'; diff --git a/plugins/search-backend-node/src/types.ts b/plugins/search-backend-node/src/types.ts index e55faccfb4..4abfa77fb1 100644 --- a/plugins/search-backend-node/src/types.ts +++ b/plugins/search-backend-node/src/types.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; +import { + DocumentCollator, + DocumentDecorator, + IndexableDocument, + SearchQuery, + SearchResultSet, +} from '@backstage/search-common'; /** * Parameters required to register a collator. @@ -51,3 +57,20 @@ export interface RegisterDecoratorParameters { */ types?: string[]; } + +/** + * A type of function responsible for translating an abstract search query into + * a concrete query relevant to a particular search engine. + */ +export type QueryTranslator = (query: SearchQuery) => unknown; + +/** + * Interface that must be implemented by specific search engines, responsible + * for performing indexing and querying and translating abstract queries into + * concrete, search engine-specific queries. + */ +export interface SearchEngine { + translator: QueryTranslator; + index(type: string, documents: IndexableDocument[]): void; + query(query: SearchQuery): Promise; +} diff --git a/plugins/search-backend/CHANGELOG.md b/plugins/search-backend/CHANGELOG.md index b6a8d9fd18..9313e2e83a 100644 --- a/plugins/search-backend/CHANGELOG.md +++ b/plugins/search-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-search-backend +## 0.1.3 + +### Patch Changes + +- b9b2b4b76: Lunr Search Engine support +- Updated dependencies [b9b2b4b76] +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/backend-common@0.6.3 + ## 0.1.2 ### Patch Changes diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index a7d588b4b1..a94fed35b9 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,8 +19,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", + "@backstage/backend-common": "^0.6.3", "@backstage/search-common": "^0.1.1", + "@backstage/plugin-search-backend-node": "^0.1.3", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -28,7 +29,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.9", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index ba7032eb93..9b7cf83ba5 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -15,6 +15,10 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { + IndexBuilder, + LunrSearchEngine, +} from '@backstage/plugin-search-backend-node'; import express from 'express'; import request from 'supertest'; @@ -24,8 +28,12 @@ describe('createRouter', () => { let app: express.Express; beforeAll(async () => { + const logger = getVoidLogger(); + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); const router = await createRouter({ - logger: getVoidLogger(), + engine: indexBuilder.getSearchEngine(), + logger, }); app = express().use(router); }); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 1a0c67bdb8..6b757fee16 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -18,23 +18,24 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { SearchQuery, SearchResultSet } from '@backstage/search-common'; +import { SearchEngine } from '@backstage/plugin-search-backend-node'; type RouterOptions = { + engine: SearchEngine; logger: Logger; }; export async function createRouter({ + engine, logger, }: RouterOptions): Promise { const router = Router(); - router.get( '/query', async ( req: express.Request, res: express.Response, ) => { - // TODO: Actually transform req.params into search engine specific query. const { term, filters = {}, pageCursor = '' } = req.query; logger.info( `Search request received: ${term}, ${JSON.stringify( @@ -43,13 +44,12 @@ export async function createRouter({ ); try { - // TODO: Actually query search engine. - // TODO: And actually transform results into frontend-readable result - res.send({ - results: [], - }); + const results = await engine?.query(req.query); + res.send(results); } catch (err) { - throw new Error(`There was a problem performing the search query.`); + throw new Error( + `There was a problem performing the search query. ${err}`, + ); } }, ); diff --git a/plugins/search-backend/src/service/standaloneServer.ts b/plugins/search-backend/src/service/standaloneServer.ts index b0c53a6adc..df42e3dda2 100644 --- a/plugins/search-backend/src/service/standaloneServer.ts +++ b/plugins/search-backend/src/service/standaloneServer.ts @@ -18,6 +18,10 @@ import { createServiceBuilder } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; +import { + LunrSearchEngine, + IndexBuilder, +} from '@backstage/plugin-search-backend-node'; export interface ServerOptions { port: number; @@ -29,8 +33,14 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'search-backend' }); + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); logger.debug('Starting application server...'); + + // TODO: stub out some documents/indices? + const router = await createRouter({ + engine: indexBuilder.getSearchEngine(), logger, }); diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index 35f3ee97c1..b4bcd1f855 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-search +## 0.3.5 + +### Patch Changes + +- dcd54c7cd: Use `RouteRef` to generate path to search page. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.4 ### Patch Changes diff --git a/plugins/search/package.json b/plugins/search/package.json index 13019c7f32..2b9db3444e 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/search-common": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -44,7 +44,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx index 7e72e83b02..b422c33e02 100644 --- a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -13,22 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback } from 'react'; +import { SidebarSearchField, useRouteRef } from '@backstage/core'; import qs from 'qs'; +import React, { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { SidebarSearchField } from '@backstage/core'; +import { rootRouteRef } from '../../plugin'; export const SidebarSearch = () => { + const searchRoute = useRouteRef(rootRouteRef); const navigate = useNavigate(); const handleSearch = useCallback( (query: string): void => { const queryString = qs.stringify({ query }, { addQueryPrefix: true }); - // TODO: Here the url to the search plugin is hardcoded. We need a way to query the route in the future. - // Maybe an API that I can just call from other places? - navigate(`/search${queryString}`); + navigate(`${searchRoute()}${queryString}`); }, - [navigate], + [navigate, searchRoute], ); return ; diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index dc45bcae60..261972384a 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sentry +## 0.3.9 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.8 ### Patch Changes diff --git a/plugins/sentry/README.md b/plugins/sentry/README.md index 52cef9cbe6..9bdb2dc16d 100644 --- a/plugins/sentry/README.md +++ b/plugins/sentry/README.md @@ -14,53 +14,43 @@ The Sentry Plugin displays issues from [Sentry](https://sentry.io). yarn add @backstage/plugin-sentry ``` -2. Add plugin to the app: - -```js -// packages/app/src/plugins.ts - -export { plugin as Sentry } from '@backstage/plugin-sentry'; -``` - -3. Add the `SentryIssuesWidget` to the EntityPage: +2. Add the `EntitySentryCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx -import { SentryIssuesWidget } from '@backstage/plugin-sentry'; +import { EntitySentryCard } from '@backstage/plugin-sentry'; -const OverviewContent = ({ entity }: { entity: Entity }) => ( +const overviewContent = ( // ... - + // ... ); ``` -> You can also import a `Router` if you want to have a dedicated sentry page: +> You can also import the full-page `EntitySentryContent` extension if you want to have a dedicated sentry page: > > ```tsx > // packages/app/src/components/catalog/EntityPage.tsx > -> import { Router as SentryRouter } from '@backstage/plugin-sentry'; +> import { EntitySentryContent } from '@backstage/plugin-sentry'; > -> const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( -> +> const serviceEntityPage = ( +> > // ... -> path="/sentry" -> title="Sentry" -> element={} -> /> +> +> +> > // ... -> +> > ); > ``` -4. Add the proxy config: +3. Add the proxy config: ```yaml # app-config.yaml @@ -76,9 +66,9 @@ sentry: organization: ``` -5. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. +4. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. -6. Add the `sentry.io/project-slug` annotation to your catalog-info.yaml file: +5. Add the `sentry.io/project-slug` annotation to your catalog-info.yaml file: ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index dee05a8c2d..e34d8bcef9 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +46,7 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 0ed86737dd..48b9292791 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sonarqube +## 0.1.17 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.1.16 ### Patch Changes diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md index 7c745edf90..5c12832e57 100644 --- a/plugins/sonarqube/README.md +++ b/plugins/sonarqube/README.md @@ -13,14 +13,7 @@ The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarclo yarn add @backstage/plugin-sonarqube ``` -2. Add plugin to the app: - -```js -// packages/app/src/plugins.ts -export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; -``` - -3. Add the `EntitySonarQubeCard` to the EntityPage: +2. Add the `EntitySonarQubeCard` to the EntityPage: ```diff // packages/app/src/components/catalog/EntityPage.tsx @@ -40,7 +33,7 @@ export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; ); ``` -4. Add the proxy config: +3. Add the proxy config: Provide a method for your Backstage backend to get to your SonarQube API end point. Add configuration to your `app-config.yaml` file depending on the product you use. @@ -77,16 +70,16 @@ sonarQube: baseUrl: https://your.sonarqube.instance.com ``` -5. Get and provide `SONARQUBE_AUTH` as an env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) +4. Get and provide `SONARQUBE_AUTH` as an env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) -6. Run the following commands in the root folder of the project to install and compile the changes. +5. Run the following commands in the root folder of the project to install and compile the changes. ```yaml yarn install yarn tsc ``` -7. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed. +6. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed. ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index d48f59febe..c31b99bac8 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.16", + "version": "0.1.17", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,8 +34,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 990d0acb27..9e93004bf5 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index 21f90ec432..7f97f88ed4 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -18,7 +18,7 @@ It serves and scales well for teams and companies of all sizes that want to have The Tech Radar can be used in two ways: -- **Simple (Recommended)** - This gives you an out-of-the-box Tech Radar experience. It lives on the `/tech-radar` URL of your Backstage installation, and you can set a variety of configuration directly in your `apis.ts`. +- **Simple (Recommended)** - This gives you an out-of-the-box Tech Radar experience. It lives on the `/tech-radar` URL of your Backstage installation. - **Advanced** - This gives you the React UI component directly. It enables you to insert the Radar on your own layout or page for a more customized feel. ### Install @@ -26,6 +26,7 @@ The Tech Radar can be used in two ways: For either simple or advanced installations, you'll need to add the dependency using Yarn: ```sh +cd packages/app yarn add @backstage/plugin-tech-radar ``` @@ -34,17 +35,16 @@ yarn add @backstage/plugin-tech-radar Modify your app routes to include the Router component exported from the tech radar, for example: ```tsx -import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; +// in packages/app/src/App.tsx +import { TechRadarPage } from '@backstage/plugin-tech-radar'; -// Inside App component - - {/* other routes ... */} - } - /> - {/* other routes ... */} -; +const routes = ( + + {/* ... */} + } + /> ``` If you'd like to configure it more, see the `TechRadarPageProps` and `TechRadarComponentProps` types for options: diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index e927ed938a..37ad7d8211 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -30,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 83b79f2f6c..6a76172638 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -90,6 +90,13 @@ export interface Config { * @visibility secret */ endpoint?: string; + /** + * (Optional) Whether to use path style URLs when communicating with S3. + * Defaults to false. + * This allows providers like LocalStack, Minio and Wasabi (and possibly others) to be used to host tech docs. + * @visibility backend + */ + s3ForcePathStyle?: boolean; }; } | { diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 3924a56df3..847121c0cf 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,53 @@ # @backstage/plugin-techdocs +## 0.9.0 + +### Minor Changes + +- 21fddf452: Make `techdocsStorageApiRef` and `techdocsApiRef` use interfaces instead of the + actual implementation classes. + + This renames the classes `TechDocsApi` to `TechDocsClient` and `TechDocsStorageApi` + to `TechDocsStorageClient` and renames the interfaces `TechDocs` to `TechDocsApi` + and `TechDocsStorage` to `TechDocsStorageApi` to comply the pattern elsewhere in + the project. This also fixes the types returned by some methods on those + interfaces. + +### Patch Changes + +- 6fbd7beca: Use `EntityRefLink` in header and use relations to reference the owner of the + document. +- 15cbe6815: Fix TechDocs landing page table wrong copied link +- 39bdaa004: Add customization and exportable components for TechDocs landing page +- cb8c848a3: Disable color transitions on links to avoid issues in dark mode. +- 17915e29b: Rework state management to avoid rendering multiple while navigating between pages. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + +## 0.8.0 + +### Minor Changes + +- ac6025f63: Add feedback link icon in Techdocs Reader that directs to GitLab or GitHub repo issue page with pre-filled title and source link. + For link to appear, requires `repo_url` and `edit_uri` to be filled in mkdocs.yml, as per https://www.mkdocs.org/user-guide/configuration. An `edit_uri` will need to be specified for self-hosted GitLab/GitHub instances with a different host name. + To identify issue URL format as GitHub or GitLab, the host name of source in `repo_url` is checked if it contains `gitlab` or `github`. Alternately this is determined by matching to `host` values from `integrations` in app-config.yaml. + +### Patch Changes + +- e292e393f: Add a test id to the shadow root element of the Reader to access it easily in e2e tests +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.7.2 ### Patch Changes diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index 7acf3c7032..e764bf82bb 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DiscoveryApi, IdentityApi } from '@backstage/core'; -import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; -import { TechDocsStorage } from '../src/api'; +import { Config } from '@backstage/config'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { NotFoundError } from '@backstage/errors'; +import { TechDocsStorageApi } from '../src/api'; -export class TechDocsDevStorageApi implements TechDocsStorage { +export class TechDocsDevStorageApi implements TechDocsStorageApi { public configApi: Config; public discoveryApi: DiscoveryApi; public identityApi: IdentityApi; diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 88b3b6a1df..632feac3c1 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.7.2", + "version": "0.9.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,14 +33,14 @@ "dependencies": { "@backstage/config": "^0.1.4", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/test-utils": "^0.1.9", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@backstage/errors": "^0.1.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/styles": "^4.10.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -49,7 +49,7 @@ "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 0be530cc08..204cc1b5a8 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core'; -import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; -import { TechDocsMetadata } from './types'; -import { NotFoundError } from '@backstage/errors'; +import { createApiRef } from '@backstage/core'; +import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', @@ -30,7 +28,10 @@ export const techdocsApiRef = createApiRef({ description: 'Used to make requests towards techdocs API', }); -export interface TechDocsStorage { +export interface TechDocsStorageApi { + getApiOrigin(): Promise; + getStorageUrl(): Promise; + getBuilder(): Promise; getEntityDocs(entityId: EntityName, path: string): Promise; syncEntityDocs(entityId: EntityName): Promise; getBaseUrl( @@ -40,227 +41,8 @@ export interface TechDocsStorage { ): Promise; } -export interface TechDocs { +export interface TechDocsApi { + getApiOrigin(): Promise; getTechDocsMetadata(entityId: EntityName): Promise; - getEntityMetadata(entityId: EntityName): Promise; -} - -/** - * API to talk to techdocs-backend. - * - * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API - */ -export class TechDocsApi implements TechDocs { - public configApi: Config; - public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; - - constructor({ - configApi, - discoveryApi, - identityApi, - }: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; - } - - async getApiOrigin() { - return ( - this.configApi.getOptionalString('techdocs.requestUrl') ?? - (await this.discoveryApi.getBaseUrl('techdocs')) - ); - } - - /** - * Retrieve TechDocs metadata. - * - * When docs are built, we generate a techdocs_metadata.json and store it along with the generated - * static files. It includes necessary data about the docs site. This method requests techdocs-backend - * which retrieves the TechDocs metadata. - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - */ - async getTechDocsMetadata(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch(`${requestUrl}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - const res = await request.json(); - - return res; - } - - /** - * Retrieve metadata about an entity. - * - * This method requests techdocs-backend which uses the catalog APIs to respond with filtered - * information required here. - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - */ - async getEntityMetadata(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch(`${requestUrl}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - const res = await request.json(); - - return res; - } -} - -/** - * API which talks to TechDocs storage to fetch files to render. - * - * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API - */ -export class TechDocsStorageApi implements TechDocsStorage { - public configApi: Config; - public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; - - constructor({ - configApi, - discoveryApi, - identityApi, - }: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; - } - - async getApiOrigin() { - return ( - this.configApi.getOptionalString('techdocs.requestUrl') ?? - (await this.discoveryApi.getBaseUrl('techdocs')) - ); - } - - async getStorageUrl() { - return ( - this.configApi.getOptionalString('techdocs.storageUrl') ?? - `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` - ); - } - - async getBuilder() { - return this.configApi.getString('techdocs.builder'); - } - - /** - * Fetch HTML content as text for an individual docs page in an entity's docs site. - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @param {string} path The unique path to an individual docs page e.g. overview/what-is-new - * @returns {string} HTML content of the docs page as string - * @throws {Error} Throws error when the page is not found. - */ - async getEntityDocs(entityId: EntityName, path: string) { - const { kind, namespace, name } = entityId; - - const storageUrl = await this.getStorageUrl(); - const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch( - `${url.endsWith('/') ? url : `${url}/`}index.html`, - { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }, - ); - - let errorMessage = ''; - switch (request.status) { - case 404: - errorMessage = 'Page not found. '; - // path is empty for the home page of an entity's docs site - if (!path) { - errorMessage += - 'This could be because there is no index.md file in the root of the docs directory of this repository.'; - } - throw new NotFoundError(errorMessage); - case 500: - errorMessage = - 'Could not generate documentation or an error in the TechDocs backend. '; - throw new Error(errorMessage); - default: - // Do nothing - break; - } - - return request.text(); - } - - /** - * Check if docs are on the latest version and trigger rebuild if not - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @returns {boolean} Whether documents are currently synchronized to newest version - * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend - */ - async syncEntityDocs(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; - const token = await this.identityApi.getIdToken(); - let request; - let attempts: number = 0; - // retry if request times out, up to 5 times - // can happen due to docs taking too long to generate - while (!request || (request.status === 408 && attempts < 5)) { - attempts++; - request = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - } - - switch (request.status) { - case 404: - throw new NotFoundError((await request.json()).error); - case 200: - case 201: - case 304: - return true; - // for timeout and misc errors, handle without error to allow viewing older docs - // if older docs not available, - // Reader will show 404 error coming from getEntityDocs - case 408: - default: - return false; - } - } - - async getBaseUrl( - oldBaseUrl: string, - entityId: EntityName, - path: string, - ): Promise { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - return new URL( - oldBaseUrl, - `${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`, - ).toString(); - } + getEntityMetadata(entityId: EntityName): Promise; } diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/client.test.ts similarity index 87% rename from plugins/techdocs/src/api.test.ts rename to plugins/techdocs/src/client.test.ts index 2260cc7d39..e8fc8f042f 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/client.test.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; import { UrlPatternDiscovery } from '@backstage/core'; -import { TechDocsStorageApi } from './api'; +import { TechDocsStorageClient } from './client'; const mockEntity = { kind: 'Component', @@ -23,7 +23,7 @@ const mockEntity = { name: 'test-component', }; -describe('TechDocsStorageApi', () => { +describe('TechDocsStorageClient', () => { const mockBaseUrl = 'http://backstage:9191/api/techdocs'; const configApi = { getOptionalString: () => 'http://backstage:9191/api/techdocs', @@ -32,7 +32,7 @@ describe('TechDocsStorageApi', () => { it('should return correct base url based on defined storage', async () => { // @ts-ignore Partial not assignable to Config. - const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + const storageApi = new TechDocsStorageClient({ configApi, discoveryApi }); await expect( storageApi.getBaseUrl('test.js', mockEntity, ''), @@ -43,7 +43,7 @@ describe('TechDocsStorageApi', () => { it('should return base url with correct entity structure', async () => { // @ts-ignore Partial not assignable to Config. - const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + const storageApi = new TechDocsStorageClient({ configApi, discoveryApi }); await expect( storageApi.getBaseUrl('test/', mockEntity, ''), diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts new file mode 100644 index 0000000000..245cfb0154 --- /dev/null +++ b/plugins/techdocs/src/client.ts @@ -0,0 +1,244 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { NotFoundError } from '@backstage/errors'; +import { TechDocsApi, TechDocsStorageApi } from './api'; +import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; + +/** + * API to talk to techdocs-backend. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ +export class TechDocsClient implements TechDocsApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin(): Promise { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + /** + * Retrieve TechDocs metadata. + * + * When docs are built, we generate a techdocs_metadata.json and store it along with the generated + * static files. It includes necessary data about the docs site. This method requests techdocs-backend + * which retrieves the TechDocs metadata. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + */ + async getTechDocsMetadata(entityId: EntityName): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const res = await request.json(); + + return res; + } + + /** + * Retrieve metadata about an entity. + * + * This method requests techdocs-backend which uses the catalog APIs to respond with filtered + * information required here. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + */ + async getEntityMetadata( + entityId: EntityName, + ): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const res = await request.json(); + + return res; + } +} + +/** + * API which talks to TechDocs storage to fetch files to render. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ +export class TechDocsStorageClient implements TechDocsStorageApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin(): Promise { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl(): Promise { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder(): Promise { + return this.configApi.getString('techdocs.builder'); + } + + /** + * Fetch HTML content as text for an individual docs page in an entity's docs site. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @param {string} path The unique path to an individual docs page e.g. overview/what-is-new + * @returns {string} HTML content of the docs page as string + * @throws {Error} Throws error when the page is not found. + */ + async getEntityDocs(entityId: EntityName, path: string): Promise { + const { kind, namespace, name } = entityId; + + const storageUrl = await this.getStorageUrl(); + const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; + const token = await this.identityApi.getIdToken(); + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, + ); + + let errorMessage = ''; + switch (request.status) { + case 404: + errorMessage = 'Page not found. '; + // path is empty for the home page of an entity's docs site + if (!path) { + errorMessage += + 'This could be because there is no index.md file in the root of the docs directory of this repository.'; + } + throw new NotFoundError(errorMessage); + case 500: + errorMessage = + 'Could not generate documentation or an error in the TechDocs backend. '; + throw new Error(errorMessage); + default: + // Do nothing + break; + } + + return request.text(); + } + + /** + * Check if docs are on the latest version and trigger rebuild if not + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @returns {boolean} Whether documents are currently synchronized to newest version + * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend + */ + async syncEntityDocs(entityId: EntityName): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + let request; + let attempts: number = 0; + // retry if request times out, up to 5 times + // can happen due to docs taking too long to generate + while (!request || (request.status === 408 && attempts < 5)) { + attempts++; + request = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + + switch (request.status) { + case 404: + throw new NotFoundError((await request.json()).error); + case 200: + case 201: + case 304: + return true; + // for timeout and misc errors, handle without error to allow viewing older docs + // if older docs not available, + // Reader will show 404 error coming from getEntityDocs + case 408: + default: + return false; + } + } + + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + return new URL( + oldBaseUrl, + `${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`, + ).toString(); + } +} diff --git a/plugins/techdocs/src/home/components/OverviewContent.test.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx similarity index 77% rename from plugins/techdocs/src/home/components/OverviewContent.test.tsx rename to plugins/techdocs/src/home/components/DocsCardGrid.test.tsx index ec67c346d7..1ddaa4c373 100644 --- a/plugins/techdocs/src/home/components/OverviewContent.test.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,13 +17,13 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; -import { OverviewContent } from './OverviewContent'; +import { DocsCardGrid } from './DocsCardGrid'; -describe('TechDocs Overview Content', () => { - it('should render all TechDocs Documents', async () => { +describe('Entity Docs Card Grid', () => { + it('should render all entities passed ot it', async () => { const { findByText } = render( wrapInTestApp( - { />, ), ); - - expect(await findByText('Overview')).toBeInTheDocument(); - expect( - await findByText( - /Explore your internal technical ecosystem through documentation./i, - ), - ).toBeInTheDocument(); expect(await findByText('testName')).toBeInTheDocument(); expect(await findByText('testName2')).toBeInTheDocument(); }); diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx new file mode 100644 index 0000000000..47f747442c --- /dev/null +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { generatePath } from 'react-router-dom'; + +import { Entity } from '@backstage/catalog-model'; +import { Button, ItemCardGrid, ItemCardHeader } from '@backstage/core'; +import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core'; + +import { rootDocsRouteRef } from '../../plugin'; + +export const DocsCardGrid = ({ + entities, +}: { + entities: Entity[] | undefined; +}) => { + if (!entities) return null; + return ( + + {!entities?.length + ? null + : entities.map((entity, index: number) => ( + + + + + {entity.metadata.description} + + + + + ))} + + ); +}; diff --git a/plugins/techdocs/src/home/components/OwnedContent.test.tsx b/plugins/techdocs/src/home/components/DocsTable.test.tsx similarity index 68% rename from plugins/techdocs/src/home/components/OwnedContent.test.tsx rename to plugins/techdocs/src/home/components/DocsTable.test.tsx index edccb0d3c6..8a7d8d9af1 100644 --- a/plugins/techdocs/src/home/components/OwnedContent.test.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,37 +16,13 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { OwnedContent } from './OwnedContent'; +import { DocsTable } from './DocsTable'; -jest.mock('../hooks', () => ({ - useOwnUser: () => { - return { - value: { - apiVersion: 'version', - kind: 'User', - metadata: { - name: 'owned', - namespace: 'default', - }, - relations: [ - { - target: { - kind: 'TestKind', - name: 'testName', - }, - type: 'ownerOf', - }, - ], - }, - }; - }, -})); - -describe('TechDocs Owned Content', () => { - it('should render TechDocs Owned Documents', async () => { - const { findByText, queryByText } = render( +describe('DocsTable test', () => { + it('should render documents passed', async () => { + const { findByText } = render( wrapInTestApp( - { ), ); - expect(await findByText('Owned documents')).toBeInTheDocument(); - expect(await findByText(/Access your documentation./i)).toBeInTheDocument(); expect(await findByText('testName')).toBeInTheDocument(); - expect(await queryByText('testName2')).not.toBeInTheDocument(); + expect(await findByText('testName2')).toBeInTheDocument(); }); it('should render empty state if no owned documents exist', async () => { - const { findByText } = render( - wrapInTestApp(), - ); + const { findByText } = render(wrapInTestApp()); expect(await findByText('No documents to show')).toBeInTheDocument(); }); diff --git a/plugins/techdocs/src/home/components/OwnedContent.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx similarity index 63% rename from plugins/techdocs/src/home/components/OwnedContent.tsx rename to plugins/techdocs/src/home/components/DocsTable.tsx index f962147345..5b4a337dea 100644 --- a/plugins/techdocs/src/home/components/OwnedContent.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,46 +20,34 @@ import { generatePath } from 'react-router-dom'; import { IconButton, Tooltip } from '@material-ui/core'; import ShareIcon from '@material-ui/icons/Share'; -import { - Content, - ContentHeader, - SupportButton, - Table, - EmptyState, - Button, - SubvalueCell, - Link, -} from '@backstage/core'; +import { Table, EmptyState, Button, SubvalueCell, Link } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { isOwnerOf } from '@backstage/plugin-catalog-react'; import { rootDocsRouteRef } from '../../plugin'; -import { useOwnUser } from '../hooks'; -export const OwnedContent = ({ +export const DocsTable = ({ entities, + title, }: { entities: Entity[] | undefined; + title?: string | undefined; }) => { const [, copyToClipboard] = useCopyToClipboard(); - const { value: user } = useOwnUser(); - if (!entities || !user) return null; + if (!entities) return null; - const ownedDocuments = entities - .filter((entity: Entity) => isOwnerOf(user, entity)) - .map(entity => { - return { + const documents = entities.map(entity => { + return { + name: entity.metadata.name, + description: entity.metadata.description, + owner: entity?.spec?.owner, + type: entity?.spec?.type, + docsUrl: generatePath(rootDocsRouteRef.path, { + namespace: entity.metadata.namespace ?? 'default', + kind: entity.kind, name: entity.metadata.name, - description: entity.metadata.description, - owner: entity?.spec?.owner, - type: entity?.spec?.type, - docsUrl: generatePath(rootDocsRouteRef.path, { - namespace: entity.metadata.namespace ?? 'default', - kind: entity.kind, - name: entity.metadata.name, - }), - }; - }); + }), + }; + }); const columns = [ { @@ -88,7 +76,7 @@ export const OwnedContent = ({ - copyToClipboard(`${window.location.origin}/${row.docsUrl}`) + copyToClipboard(`${window.location.href}/${row.docsUrl}`) } > @@ -99,19 +87,21 @@ export const OwnedContent = ({ ]; return ( - - - Discover documentation you own. - - {ownedDocuments && ownedDocuments.length > 0 ? ( + <> + {documents && documents.length > 0 ? (
) : ( )} - + ); }; diff --git a/plugins/techdocs/src/home/components/OverviewContent.tsx b/plugins/techdocs/src/home/components/OverviewContent.tsx deleted file mode 100644 index e0030691f5..0000000000 --- a/plugins/techdocs/src/home/components/OverviewContent.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { generatePath } from 'react-router-dom'; - -import { Entity } from '@backstage/catalog-model'; -import { - Button, - Content, - ContentHeader, - SupportButton, - ItemCardGrid, - ItemCardHeader, -} from '@backstage/core'; -import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core'; - -import { rootDocsRouteRef } from '../../plugin'; - -export const OverviewContent = ({ - entities, -}: { - entities: Entity[] | undefined; -}) => { - if (!entities) return null; - return ( - - - Discover documentation in your ecosystem. - - - {!entities?.length - ? null - : entities.map((entity, index: number) => ( - - - - - {entity.metadata.description} - - - - - ))} - - - ); -}; diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx new file mode 100644 index 0000000000..9ee32fef13 --- /dev/null +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome'; + +describe('TechDocsCustomHome', () => { + const catalogApi: Partial = { + getEntities: async () => ({ items: [] }), + }; + + const apiRegistry = ApiRegistry.with(catalogApiRef, catalogApi); + + it('should render a TechDocs home page', async () => { + const tabsConfig = [ + { + label: 'First Tab', + panels: [ + { + title: 'First Tab', + description: 'First Tab Description', + panelType: 'DocsCardGrid' as PanelType, + filterPredicate: () => true, + }, + ], + }, + { + label: 'Second Tab ', + panels: [ + { + title: 'Second Tab', + description: 'Second Tab Description', + panelType: 'DocsTable' as PanelType, + filterPredicate: () => true, + }, + ], + }, + ]; + + await renderInTestApp( + + + , + ); + + // Header + expect(await screen.findByText('Documentation')).toBeInTheDocument(); + expect( + await screen.findByText(/Documentation available in Backstage/i), + ).toBeInTheDocument(); + + // Explore Content + expect(await screen.findByTestId('docs-explore')).toBeDefined(); + + // Tabs + expect(await screen.findAllByText('First Tab')).toHaveLength(2); + expect(await screen.findByText('Second Tab')).toBeInTheDocument(); + expect( + await screen.findByText('First Tab Description'), + ).toBeInTheDocument(); + (await screen.findByText('Second Tab')).click(); + expect( + await screen.findByText('Second Tab Description'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx new file mode 100644 index 0000000000..ff9f1bf101 --- /dev/null +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { useAsync } from 'react-use'; +import { makeStyles } from '@material-ui/core'; +import { CSSProperties } from '@material-ui/styles'; +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { + CodeSnippet, + Content, + ConfigApi, + configApiRef, + Header, + HeaderTabs, + Page, + Progress, + useApi, + WarningPanel, + SupportButton, + ContentHeader, +} from '@backstage/core'; +import { DocsTable } from './DocsTable'; +import { DocsCardGrid } from './DocsCardGrid'; + +const panels = { + DocsTable: DocsTable, + DocsCardGrid: DocsCardGrid, +}; + +export type PanelType = 'DocsCardGrid' | 'DocsTable'; + +export interface PanelConfig { + title: string; + description: string; + panelType: PanelType; + panelCSS?: CSSProperties; + filterPredicate: (entity: Entity) => boolean; +} + +export interface TabConfig { + label: string; + panels: PanelConfig[]; +} + +export type TabsConfig = TabConfig[]; + +const CustomPanel = ({ + config, + entities, + index, +}: { + config: PanelConfig; + entities: Entity[]; + index: number; +}) => { + const useStyles = makeStyles({ + panelContainer: { + marginBottom: '2rem', + ...(config.panelCSS ? config.panelCSS : {}), + }, + }); + const classes = useStyles(); + const Panel = panels[config.panelType]; + const shownEntities = entities.filter(config.filterPredicate); + return ( + <> + + {index === 0 ? ( + + Discover documentation in your ecosystem. + + ) : null} + +
+ +
+ + ); +}; + +export const TechDocsCustomHome = ({ + tabsConfig, +}: { + tabsConfig: TabsConfig; +}) => { + const [selectedTab, setSelectedTab] = useState(0); + const catalogApi: CatalogApi = useApi(catalogApiRef); + const configApi: ConfigApi = useApi(configApiRef); + + const { value: entities, loading, error } = useAsync(async () => { + const response = await catalogApi.getEntities(); + return response.items.filter((entity: Entity) => { + return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; + }); + }); + + const generatedSubtitle = `Documentation available in ${ + configApi.getOptionalString('organization.name') ?? 'Backstage' + }`; + + const currentTabConfig = tabsConfig[selectedTab]; + + if (loading) { + return ( + +
+ + + + + ); + } + + if (error) { + return ( + +
+ + + + + + + ); + } + + return ( + +
+ setSelectedTab(index)} + tabs={tabsConfig.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + {currentTabConfig.panels.map((config, index) => ( + + ))} + + + ); +}; diff --git a/plugins/techdocs/src/home/components/TechDocsHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsHome.test.tsx index 8067767d39..566ed3bd0b 100644 --- a/plugins/techdocs/src/home/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/TechDocsHome.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,30 @@ import { screen } from '@testing-library/react'; import React from 'react'; import { TechDocsHome } from './TechDocsHome'; +jest.mock('../hooks', () => ({ + useOwnUser: () => { + return { + value: { + apiVersion: 'version', + kind: 'User', + metadata: { + name: 'owned', + namespace: 'default', + }, + relations: [ + { + target: { + kind: 'TestKind', + name: 'testName', + }, + type: 'ownerOf', + }, + ], + }, + }; + }, +})); + describe('TechDocs Home', () => { const catalogApi: Partial = { getEntities: async () => ({ items: [] }), diff --git a/plugins/techdocs/src/home/components/TechDocsHome.tsx b/plugins/techdocs/src/home/components/TechDocsHome.tsx index 5e9e25e7a0..72f59d5446 100644 --- a/plugins/techdocs/src/home/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsHome.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,88 +14,44 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { useAsync } from 'react-use'; - -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; +import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { - CodeSnippet, - ConfigApi, - configApiRef, - Content, - Header, - HeaderTabs, - Page, - Progress, - useApi, - WarningPanel, -} from '@backstage/core'; - -import { OverviewContent } from './OverviewContent'; -import { OwnedContent } from './OwnedContent'; +import { useOwnUser } from '../hooks'; +import { isOwnerOf } from '@backstage/plugin-catalog-react'; +import { PanelType, TechDocsCustomHome } from './TechDocsCustomHome'; export const TechDocsHome = () => { - const [selectedTab, setSelectedTab] = useState(0); - const catalogApi: CatalogApi = useApi(catalogApiRef); - const configApi: ConfigApi = useApi(configApiRef); + const { value: user } = useOwnUser(); - const tabs = [{ label: 'Overview' }, { label: 'Owned Documents' }]; - - const { value, loading, error } = useAsync(async () => { - const response = await catalogApi.getEntities(); - return response.items.filter((entity: Entity) => { - return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; - }); - }); - - const generatedSubtitle = `Documentation available in ${ - configApi.getOptionalString('organization.name') ?? 'Backstage' - }`; - - if (loading) { - return ( - -
- - - - - ); - } - - if (error) { - return ( - -
- - - - - - - ); - } - - return ( - -
- setSelectedTab(index)} - tabs={tabs.map(({ label }, index) => ({ - id: index.toString(), - label, - }))} - /> - {selectedTab === 0 ? ( - - ) : ( - - )} - - ); + const tabsConfig = [ + { + label: 'Overview', + panels: [ + { + title: 'Overview', + description: + 'Explore your internal technical ecosystem through documentation.', + panelType: 'DocsCardGrid' as PanelType, + filterPredicate: () => true, + }, + ], + }, + { + label: 'Owned Documents', + panels: [ + { + title: 'Owned documents', + description: 'Access your documentation.', + panelType: 'DocsTable' as PanelType, + filterPredicate: (entity: Entity) => { + if (!user) { + return false; + } + return isOwnerOf(user, entity); + }, + }, + ], + }, + ]; + return ; }; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 30c143e31f..3bdf8bfec1 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -14,12 +14,20 @@ * limitations under the License. */ -export { - techdocsPlugin, - techdocsPlugin as plugin, - TechdocsPage, - EntityTechdocsContent, -} from './plugin'; -export { Router, EmbeddedDocsRouter } from './Router'; -export * from './reader'; export * from './api'; +export { techdocsApiRef, techdocsStorageApiRef } from './api'; +export type { TechDocsApi, TechDocsStorageApi } from './api'; +export { TechDocsClient, TechDocsStorageClient } from './client'; +export type { PanelType } from './home/components/TechDocsCustomHome'; +export { + DocsCardGrid, + DocsTable, + EntityTechdocsContent, + TechDocsCustomHome, + TechdocsPage, + techdocsPlugin as plugin, + techdocsPlugin, + TechDocsReaderPage, +} from './plugin'; +export * from './reader'; +export { EmbeddedDocsRouter, Router } from './Router'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index a945ca1494..97dd6f2150 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -30,20 +30,17 @@ */ import { - createPlugin, - createRouteRef, - createApiFactory, configApiRef, + createApiFactory, + createComponentExtension, + createPlugin, + createRoutableExtension, + createRouteRef, discoveryApiRef, identityApiRef, - createRoutableExtension, } from '@backstage/core'; -import { - techdocsStorageApiRef, - TechDocsStorageApi, - techdocsApiRef, - TechDocsApi, -} from './api'; +import { techdocsApiRef, techdocsStorageApiRef } from './api'; +import { TechDocsClient, TechDocsStorageClient } from './client'; export const rootRouteRef = createRouteRef({ path: '', @@ -71,7 +68,7 @@ export const techdocsPlugin = createPlugin({ identityApi: identityApiRef, }, factory: ({ configApi, discoveryApi, identityApi }) => - new TechDocsStorageApi({ + new TechDocsStorageClient({ configApi, discoveryApi, identityApi, @@ -85,7 +82,7 @@ export const techdocsPlugin = createPlugin({ identityApi: identityApiRef, }, factory: ({ configApi, discoveryApi, identityApi }) => - new TechDocsApi({ + new TechDocsClient({ configApi, discoveryApi, identityApi, @@ -111,3 +108,41 @@ export const EntityTechdocsContent = techdocsPlugin.provide( mountPoint: rootCatalogDocsRouteRef, }), ); + +// takes a list of entities and renders documentation cards +export const DocsCardGrid = techdocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./home/components/DocsCardGrid').then(m => m.DocsCardGrid), + }, + }), +); + +// takes a list of entities and renders table listing documentation +export const DocsTable = techdocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./home/components/DocsTable').then(m => m.DocsTable), + }, + }), +); + +// takes a custom tabs config object and renders a documentation landing page +export const TechDocsCustomHome = techdocsPlugin.provide( + createRoutableExtension({ + component: () => + import('./home/components/TechDocsCustomHome').then( + m => m.TechDocsCustomHome, + ), + mountPoint: rootRouteRef, + }), +); + +export const TechDocsReaderPage = techdocsPlugin.provide( + createRoutableExtension({ + component: () => + import('./reader/components/TechDocsPage').then(m => m.TechDocsPage), + mountPoint: rootDocsRouteRef, + }), +); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index ff4f47dca0..b16a566afa 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { EntityName } from '@backstage/catalog-model'; -import { useApi, configApiRef } from '@backstage/core'; +import { configApiRef, useApi } from '@backstage/core'; import { BackstageTheme } from '@backstage/theme'; import { useTheme } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; @@ -24,6 +24,7 @@ import { useAsync } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; import transformer, { addBaseUrl, + addGitFeedbackLink, addLinkClickListener, injectCss, onCssReady, @@ -31,10 +32,10 @@ import transformer, { rewriteDocLinks, sanitizeDOM, simplifyMkdocsFooter, - addGitFeedbackLink, } from '../transformers'; import { TechDocsNotFound } from './TechDocsNotFound'; import TechDocsProgressBar from './TechDocsProgressBar'; +import { useRawPage } from './useRawPage'; type Props = { entityId: EntityName; @@ -69,20 +70,20 @@ export const Reader = ({ entityId, onReady }: Props) => { }); } return techdocsStorageApi.syncEntityDocs({ kind, namespace, name }); - }); + }, [techdocsStorageApi, kind, namespace, name]); const { value: rawPage, loading: docLoading, error: docLoadError, - } = useAsync(async () => { - // do not automatically load same page again if URL has not changed, - // happens when generating new docs finishes - if (newerDocsExist && path === loadedPath) { - return null; + retry, + } = useRawPage(path, kind, namespace, name); + + useEffect(() => { + if (isSynced && newerDocsExist && path !== loadedPath) { + retry(); } - return techdocsStorageApi.getEntityDocs({ kind, namespace, name }, path); - }, [techdocsStorageApi, kind, namespace, name, path, isSynced]); + }); useEffect(() => { const updateSidebarPosition = () => { @@ -134,12 +135,12 @@ export const Reader = ({ entityId, onReady }: Props) => { onReady(); } // Pre-render - const transformedElement = transformer(rawPage as string, [ + const transformedElement = transformer(rawPage.content, [ sanitizeDOM(), addBaseUrl({ techdocsStorageApi, - entityId: entityId, - path, + entityId: rawPage.entityId, + path: rawPage.path, }), rewriteDocLinks(), removeMkdocsHeader(), @@ -197,10 +198,21 @@ export const Reader = ({ entityId, onReady }: Props) => { } .md-nav--primary > .md-nav__title [for="none"] { padding-top: 0; - } + } } `, }), + injectCss({ + // Disable CSS animations on link colors as they lead to issues in dark + // mode. The dark mode color theme is applied later and theirfore there + // is always an animation from light to dark mode when navigation + // between pages. + css: ` + .md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { + transition: none; + } + `, + }), injectCss({ // Admonitions and others are using SVG masks to define icons. These // masks are defined as CSS variables. @@ -304,16 +316,15 @@ export const Reader = ({ entityId, onReady }: Props) => { ]); }, [ rawPage, - entityId, navigate, onReady, shadowDomRef, - path, techdocsStorageApi, - theme, - kind, - namespace, - name, + theme.typography.fontFamily, + theme.palette.text.primary, + theme.palette.primary.main, + theme.palette.background.paper, + theme.palette.background.default, newerDocsExist, isSynced, configApi, diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index 4b578bef38..52ddd3794e 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -51,8 +51,19 @@ describe('', () => { }); const techdocsApi: Partial = { - getEntityMetadata: () => Promise.resolve([]), - getTechDocsMetadata: () => Promise.resolve([]), + getEntityMetadata: () => + Promise.resolve({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'backstage', + }, + }), + getTechDocsMetadata: () => + Promise.resolve({ + site_name: 'string', + site_description: 'string', + }), }; const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx index f2635c0dfb..338088be4d 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; import { Content, Page, useApi } from '@backstage/core'; -import { Reader } from './Reader'; +import React, { useCallback, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; -import { TechDocsPageHeader } from './TechDocsPageHeader'; import { techdocsApiRef } from '../../api'; +import { Reader } from './Reader'; +import { TechDocsPageHeader } from './TechDocsPageHeader'; export const TechDocsPage = () => { const [documentReady, setDocumentReady] = useState(false); @@ -33,16 +33,16 @@ export const TechDocsPage = () => { return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); } - return Promise.resolve({ loading: true }); + return Promise.resolve(undefined); }, [kind, namespace, name, techdocsApi, documentReady]); const entityMetadataRequest = useAsync(() => { return techdocsApi.getEntityMetadata({ kind, namespace, name }); }, [kind, namespace, name, techdocsApi]); - const onReady = () => { + const onReady = useCallback(() => { setDocumentReady(true); - }; + }, [setDocumentReady]); return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index 1d4321001d..10df7bff78 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -14,14 +14,18 @@ * limitations under the License. */ +import { EntityName, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { Header, HeaderLabel, useRouteRef } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import CodeIcon from '@material-ui/icons/Code'; import React from 'react'; import { AsyncState } from 'react-use/lib/useAsync'; -import CodeIcon from '@material-ui/icons/Code'; -import { EntityName, parseEntityName } from '@backstage/catalog-model'; -import { Header, HeaderLabel, Link, useRouteRef } from '@backstage/core'; -import { TechDocsMetadata } from '../../types'; -import { EntityRefLink, entityRouteRef } from '@backstage/plugin-catalog-react'; import { rootRouteRef } from '../../plugin'; +import { TechDocsMetadata } from '../../types'; type TechDocsPageHeaderProps = { entityId: EntityName; @@ -50,15 +54,12 @@ export const TechDocsPageHeader = ({ const { locationMetadata, - spec: { owner, lifecycle }, + spec: { lifecycle }, } = entityMetadataValues || { spec: {} }; - const componentLink = useRouteRef(entityRouteRef); - - let ownerEntity; - if (owner) { - ownerEntity = parseEntityName(owner, { defaultKind: 'group' }); - } + const ownedByRelations = entityMetadataValues + ? getEntityRelations(entityMetadataValues, RELATION_OWNED_BY) + : []; const docsRootLink = useRouteRef(rootRouteRef)(); @@ -67,27 +68,25 @@ export const TechDocsPageHeader = ({ - {name} - + } /> - {owner ? ( + {ownedByRelations.length > 0 && ( - ) : ( - owner - ) + } /> - ) : null} + )} {lifecycle ? : null} {locationMetadata && locationMetadata.type !== 'dir' && diff --git a/plugins/techdocs/src/reader/components/useRawPage.ts b/plugins/techdocs/src/reader/components/useRawPage.ts new file mode 100644 index 0000000000..1bc23d2a45 --- /dev/null +++ b/plugins/techdocs/src/reader/components/useRawPage.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EntityName } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { useAsyncRetry } from 'react-use'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { techdocsStorageApiRef } from '../../api'; + +export type RawPage = { + content: string; + path: string; + entityId: EntityName; +}; + +export function useRawPage( + path: string, + kind: string, + namespace: string, + name: string, +): AsyncState & { + retry(): void; +} { + const techdocsStorageApi = useApi(techdocsStorageApiRef); + + return useAsyncRetry(async () => { + const content = await techdocsStorageApi.getEntityDocs( + { kind, namespace, name }, + path, + ); + + return { + content, + path, + entityId: { + kind, + name, + namespace, + }, + }; + }, [techdocsStorageApi, kind, namespace, name, path]); +} diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 5ba86e42ca..4311de5dae 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -16,14 +16,17 @@ import { createTestShadowDom } from '../../test-utils'; import { addBaseUrl } from '../transformers'; -import { TechDocsStorage } from '../../api'; +import { TechDocsStorageApi } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; -const techdocsStorageApi: TechDocsStorage = { +const techdocsStorageApi: TechDocsStorageApi = { getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), syncEntityDocs: () => new Promise(resolve => resolve(true)), + getApiOrigin: jest.fn(), + getBuilder: jest.fn(), + getStorageUrl: jest.fn(), }; const fixture = ` diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 2872ad36cc..17070dcfe0 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -15,10 +15,10 @@ */ import { EntityName } from '@backstage/catalog-model'; import type { Transformer } from './index'; -import { TechDocsStorage } from '../../api'; +import { TechDocsStorageApi } from '../../api'; type AddBaseUrlOptions = { - techdocsStorageApi: TechDocsStorage; + techdocsStorageApi: TechDocsStorageApi; entityId: EntityName; path: string; }; diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index 36cc4e187b..fb068bee09 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ +import { Entity, Location } from '@backstage/catalog-model'; + export type TechDocsMetadata = { site_name: string; site_description: string; }; + +export type TechDocsEntityMetadata = Entity & { locationMetadata?: Location }; diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 5a80f068cc..92390857ef 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -27,10 +27,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -39,7 +39,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 6055f31b8a..08ae87d3ec 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/welcome/CHANGELOG.md b/plugins/welcome/CHANGELOG.md index 68b8c358c2..cf907b5b4f 100644 --- a/plugins/welcome/CHANGELOG.md +++ b/plugins/welcome/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-welcome +## 0.2.7 + +### Patch Changes + +- f85851837: Australian Greeting +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.2.6 ### Patch Changes diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index aa7a746016..189b6eeb59 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.7", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodAfternoon.locales.json b/plugins/welcome/src/utils/locales/goodAfternoon.locales.json similarity index 98% rename from plugins/catalog/src/components/CatalogPage/utils/locales/goodAfternoon.locales.json rename to plugins/welcome/src/utils/locales/goodAfternoon.locales.json index 4d48666666..9010ba4359 100644 --- a/plugins/catalog/src/components/CatalogPage/utils/locales/goodAfternoon.locales.json +++ b/plugins/welcome/src/utils/locales/goodAfternoon.locales.json @@ -11,6 +11,7 @@ "Armenian": "Բարի կէսօր:", "Assamese": "শুভ আবেলি", "Asturian": "Bones tardes", + "Australian": "G'day", "Azerbaijani": "Günortanız xeyir", "Bashkir": "хәйерле көн", "Basque": "Arratsalde on", @@ -68,6 +69,7 @@ "Latvian": "Labdien", "Limburgish": "Gojemiddig", "Lithuanian": "Laba diena", + "Low German": "Moin", "Lozi": "Ki musihali", "Lule Sámi": "Buorre biejvve", "Luxembourgish": "Gudde Mëtteg", diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json b/plugins/welcome/src/utils/locales/goodEvening.locales.json similarity index 95% rename from plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json rename to plugins/welcome/src/utils/locales/goodEvening.locales.json index 890899fcf3..5a98abafaa 100644 --- a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json +++ b/plugins/welcome/src/utils/locales/goodEvening.locales.json @@ -10,6 +10,7 @@ "Asante": "Maadwó", "Asturian": "Bona nuechi", "Ateso": "Akwar najokan", + "Australian": "G'day", "Aymara": "Winas tartis", "Azerbaijani": "Axşamınız xeyir", "Basque": "Arratsalde on", @@ -56,6 +57,7 @@ "Kurdish": "Evarbash", "Latvian": "Labvakar", "Lithuanian": "Labas vakaras", + "Low German": "Moin", "Luganda": "Osiibye otya nno", "Luo": "Oimore", "Luxembourgish": "Gudden Owend", @@ -75,7 +77,7 @@ "Quiché": "Xe q’ij", "Romani [Sinte]": "Lashi rachi", "Romanian": "Bunã seara", - "Russian": "Dobry vyecher", + "Russian": "Добрый вечер", "Scottish Gaelic": "Feasgar mhath", "Scots": "Guid eenin", "Sesotho": "Fonane", @@ -91,7 +93,7 @@ "Telugu": "శుభ సాయంత్రం", "Thai": "Sawat-dii torn khum", "Turkish": "İyi akşamlar", - "Ukrainian": "Dobry vechir", + "Ukrainian": "Добрий вечiр", "Uzbek": "Xayrli kech", "Welsh": "Noswaith dda", "Yiddish": "Ah gutn ovnt", diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodMorning.locales.json b/plugins/welcome/src/utils/locales/goodMorning.locales.json similarity index 97% rename from plugins/catalog/src/components/CatalogPage/utils/locales/goodMorning.locales.json rename to plugins/welcome/src/utils/locales/goodMorning.locales.json index febbc32c49..a9629edbbb 100644 --- a/plugins/catalog/src/components/CatalogPage/utils/locales/goodMorning.locales.json +++ b/plugins/welcome/src/utils/locales/goodMorning.locales.json @@ -4,6 +4,7 @@ "Albanian": "Mirëmëngjes", "Arabic": "صباح الخير", "Armenian": "Paree looys", + "Australian": "G'day", "Azerbaijani": "Sabahınız xeyir", "Bengali": "shuprobhat", "Bulgarian": "Dobro utro", @@ -32,6 +33,7 @@ "Kazakh": "Kayırlı tan", "Kurdish": "Beyanî baş", "Lithuanian": "Labas rytas", + "Low German": "Moin", "Macedonian": "Добро утро", "Malay": "Selamat pagi", "Maltese": "Bonġu", diff --git a/plugins/catalog/src/components/CatalogPage/utils/timeUtil.js b/plugins/welcome/src/utils/timeUtil.js similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/timeUtil.js rename to plugins/welcome/src/utils/timeUtil.js diff --git a/plugins/catalog/src/components/CatalogPage/utils/timeUtil.test.js b/plugins/welcome/src/utils/timeUtil.test.js similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/timeUtil.test.js rename to plugins/welcome/src/utils/timeUtil.test.js diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts new file mode 100644 index 0000000000..37dc4769fd --- /dev/null +++ b/scripts/api-extractor.ts @@ -0,0 +1,327 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +// eslint-disable-next-line no-restricted-imports +import { + resolve as resolvePath, + relative as relativePath, + dirname, +} from 'path'; +import fs from 'fs-extra'; +import { + Extractor, + ExtractorConfig, + CompilerState, + ExtractorLogLevel, +} from '@microsoft/api-extractor'; +import { ApiPackage, ApiModel } from '@microsoft/api-extractor-model'; +import { MarkdownDocumenter } from '@microsoft/api-documenter/lib/documenters/MarkdownDocumenter'; + +const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); + +/** + * All of this monkey patching below is because MUI has these bare package.json file as a method + * for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking + * by declaring them side effect free. + * + * The package.json lookup logic in api-extractor really doesn't like that though, as it enforces + * that the 'name' field exists in all package.json files that it discovers. This below is just + * making sure that we ignore those file package.json files instead of crashing. + */ +const { + PackageJsonLookup, +} = require('@rushstack/node-core-library/lib/PackageJsonLookup'); + +const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor; +PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = function tryGetPackageJsonFilePathForPatch( + path: string, +) { + if ( + path.includes('@material-ui') && + !dirname(path).endsWith('@material-ui') + ) { + return undefined; + } + return old.call(this, path); +}; + +const DOCUMENTED_PACKAGES = [ + 'packages/backend-common', + 'packages/catalog-client', + 'packages/catalog-model', + 'packages/cli-common', + 'packages/config', + 'packages/config-loader', + // TODO(Rugvip): Enable these once `import * as ...` and `import()` PRs have landed, #1796 & #1916. + // 'packages/core', + // 'packages/core-api', + 'packages/dev-utils', + 'packages/errors', + 'packages/integration', + 'packages/integration-react', + 'packages/search-common', + 'packages/techdocs-common', + 'packages/test-utils', + 'packages/test-utils-core', + 'packages/theme', +]; + +interface ApiExtractionOptions { + packageDirs: string[]; + outputDir: string; + isLocalBuild: boolean; +} +async function runApiExtraction({ + packageDirs, + outputDir, + isLocalBuild, +}: ApiExtractionOptions) { + await fs.remove(outputDir); + + const entryPoints = packageDirs.map(packageDir => { + return resolvePath(__dirname, `../dist-types/${packageDir}/src/index.d.ts`); + }); + + let compilerState: CompilerState | undefined = undefined; + + for (const packageDir of packageDirs) { + console.log(`## Processing ${packageDir}`); + const projectFolder = resolvePath(__dirname, '..', packageDir); + const packagePath = resolvePath(__dirname, `../${packageDir}/package.json`); + + const extractorConfig = ExtractorConfig.prepare({ + configObject: { + mainEntryPointFilePath: resolvePath( + __dirname, + '../dist-types/packages//src/index.d.ts', + ), + bundledPackages: [], + + compiler: { + tsconfigFilePath: resolvePath(__dirname, '../tsconfig.json'), + }, + + apiReport: { + enabled: true, + reportFileName: 'api-report.md', + reportFolder: projectFolder, + reportTempFolder: resolvePath(outputDir, ''), + }, + + docModel: { + enabled: true, + apiJsonFilePath: resolvePath( + outputDir, + '.api.json', + ), + }, + + dtsRollup: { + enabled: false, + }, + + tsdocMetadata: { + enabled: false, + }, + + messages: { + // Silence warnings, as these will prevent the CI build to work + compilerMessageReporting: { + default: { + logLevel: 'none' as ExtractorLogLevel.None, + // These contain absolute file paths, so can't be included in the report + // addToApiReportFile: true, + }, + }, + extractorMessageReporting: { + default: { + logLevel: 'none' as ExtractorLogLevel.Warning, + // addToApiReportFile: true, + }, + }, + tsdocMessageReporting: { + default: { + logLevel: 'none' as ExtractorLogLevel.Warning, + // addToApiReportFile: true, + }, + }, + }, + + newlineKind: 'lf', + + projectFolder, + }, + configObjectFullPath: projectFolder, + packageJsonFullPath: packagePath, + }); + + if (!compilerState) { + compilerState = CompilerState.create(extractorConfig, { + additionalEntryPoints: entryPoints, + }); + } + + // Message verbosity can't be configured, so just skip the check instead + (Extractor as any)._checkCompilerCompatibility = () => {}; + + let shouldLogInstructions = false; + let conflictingFile: undefined | string = undefined; + + // Invoke API Extractor + const extractorResult = Extractor.invoke(extractorConfig, { + localBuild: isLocalBuild, + showVerboseMessages: false, + showDiagnostics: false, + messageCallback(message) { + if ( + message.text.includes( + 'You have changed the public API signature for this project.', + ) + ) { + shouldLogInstructions = true; + const match = message.text.match( + /Please copy the file "(.*)" to "api-report\.md"/, + ); + if (match) { + conflictingFile = match[1]; + } + } + }, + compilerState, + }); + + if (!extractorResult.succeeded) { + if (shouldLogInstructions) { + console.log(''); + console.log( + '*************************************************************************************', + ); + console.log( + '* You have uncommitted changes to the public API of a package. *', + ); + console.log( + '* To solve this, run `yarn build:api-reports` and commit all api-report.md changes. *', + ); + console.log( + '*************************************************************************************', + ); + console.log(''); + + if (conflictingFile) { + console.log(''); + console.log( + `The conflicting file is ${relativePath( + tmpDir, + conflictingFile, + )}, with the following content:`, + ); + console.log(''); + + const content = await fs.readFile(conflictingFile, 'utf8'); + console.log(content); + console.log(''); + } + } + + throw new Error( + `API Extractor completed with ${extractorResult.errorCount} errors` + + ` and ${extractorResult.warningCount} warnings`, + ); + } + } +} + +function isComponentMember(member: any) { + // React components are annotated with @component, and we want to skip those + return Boolean(member.docComment.match(/\n\s*\**\s*@component/m)); +} + +async function buildDocs({ + inputDir, + outputDir, +}: { + inputDir: string; + outputDir: string; +}) { + const parseFile = async (filename: string): Promise => { + console.log(`Reading ${filename}`); + return fs.readJson(resolvePath(inputDir, filename)); + }; + + const filenames = await fs.readdir(inputDir); + const serializedPackages = await Promise.all( + filenames + .filter(filename => filename.match(/\.api\.json$/i)) + .map(parseFile), + ); + + const newModel = new ApiModel(); + for (const serialized of serializedPackages) { + serialized.members[0].members = serialized.members[0].members.filter( + member => !isComponentMember(member), + ); + + const pkg = ApiPackage.deserialize( + serialized, + serialized.metadata, + ) as ApiPackage; + newModel.addMember(pkg); + } + + await fs.remove(outputDir); + await fs.ensureDir(outputDir); + + const documenter = new MarkdownDocumenter({ + apiModel: newModel, + documenterConfig: { + outputTarget: 'markdown', + newlineKind: '\n', + // De ba dålig kod + configFilePath: '', + configFile: {}, + } as any, + outputFolder: outputDir, + }); + + documenter.generateFiles(); +} + +async function main() { + const isCiBuild = process.argv.includes('--ci'); + const isDocsBuild = process.argv.includes('--docs'); + + console.log('# Generating package API reports'); + await runApiExtraction({ + packageDirs: DOCUMENTED_PACKAGES, + outputDir: tmpDir, + isLocalBuild: !isCiBuild, + }); + + if (isDocsBuild) { + console.log('# Generating package documentation'); + await buildDocs({ + inputDir: tmpDir, + outputDir: resolvePath(__dirname, '..', 'docs/reference'), + }); + } +} + +main().catch(error => { + console.error(error.stack || String(error)); + process.exit(1); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000000..c8d842173c --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/yarn.lock b/yarn.lock index 3c563eee9e..8a73d19c38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,22 +69,22 @@ dependencies: tslib "~2.0.1" -"@asyncapi/avro-schema-parser@^0.2.0": - version "0.2.0" - resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.2.0.tgz#c9da2bb2858aca5a3b9e9a0e600084afd3765551" - integrity sha512-/oPDPudF82RGFXz5uhF77PSDZkAR+yHpdxUEbJ5jwk/X3RB74VRk8dqSgTqhUO+pLh+/Hut3x3VFacA8C9L2QA== +"@asyncapi/avro-schema-parser@^0.2.1": + version "0.2.1" + resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.2.1.tgz#0b7d9953e12084e6f556db261ae08bd8f6690faa" + integrity sha512-RZJaHsdYM4dChYSrb/TWrVCn/r2qcus+9/8iLL8+SMINHb0ECgH8tFZFJpr3Tq+LV2SBFaRQ+9kuecjQ8BNDSA== -"@asyncapi/openapi-schema-parser@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-2.0.0.tgz#80e2f38e92b6635dde19aae07b92e3caa0effc58" - integrity sha512-XfDp3EIs6ptar3jARQZzi3ObmS44l6Qozc5GJmZJUQ6mHLTTqUGJ0nxcrXAW88vosjilgJVaQ63oGolA6smSHQ== +"@asyncapi/openapi-schema-parser@^2.0.1": + version "2.0.1" + resolved "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-2.0.1.tgz#4d6e82cced907b14e0ad6f98261ff2562d968d96" + integrity sha512-algbtdM1gcAOa8+V8kp7WeBhdaNac82jmZUXx8YjyNfRVo02N2juDrjeBAGJd+FNva9Mb4MM7qfkJoAFpTL5VQ== dependencies: "@openapi-contrib/openapi-schema-to-json-schema" "^3.0.0" -"@asyncapi/parser@^1.4.4": - version "1.4.4" - resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.4.4.tgz#66f2642e3f9ae4166cdea2480b665250b1edbd59" - integrity sha512-HEYEDM0BzfCxXNAv/pIS5yZWe11xB8fQo9G/SmsbpJavOpcF0sVZVIELva/NxHVz/ZUKPMKCa4Gqz7DF/lMqpw== +"@asyncapi/parser@^1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.5.0.tgz#d70616a1e6081c7dd86957befd5dccc46b9a77df" + integrity sha512-HzrehCcT6R+iqtktNmrUM9wRUEMBqnCeXIrGJT0txBMS1QduNRmXGjvuDpxqwyaATPH/xu1gSp2l6pFP/hyVbA== dependencies: "@apidevtools/json-schema-ref-parser" "^9.0.6" "@asyncapi/specs" "^2.7.7" @@ -96,14 +96,14 @@ node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" -"@asyncapi/react-component@^0.22.3": - version "0.22.3" - resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.22.3.tgz#6ea7fb1044308e6d46f8455218920389becf2b22" - integrity sha512-f47sboqEQ0jNp0z2A+WGzBYYMS3ASmTwAXG/q6SwLuHBW15bSjaIYOVK3E8bmftBl+wVcEgAqUc6RKGtD9iVJg== +"@asyncapi/react-component@^0.23.0": + version "0.23.0" + resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.23.0.tgz#dce9a95cd0fb0d9f0364278088ad31194d411d52" + integrity sha512-mX70k3j5eSM7ets8BBZWnu6RsNXapLKD/7np+dMeXFk7KFbigVQXXr3hS0k2R+c8tRfkhlklicibq5ar1+fjDA== dependencies: - "@asyncapi/avro-schema-parser" "^0.2.0" - "@asyncapi/openapi-schema-parser" "^2.0.0" - "@asyncapi/parser" "^1.4.4" + "@asyncapi/avro-schema-parser" "^0.2.1" + "@asyncapi/openapi-schema-parser" "^2.0.1" + "@asyncapi/parser" "^1.5.0" constate "^1.2.0" dompurify "^2.1.1" markdown-it "^11.0.1" @@ -2330,27 +2330,16 @@ "@graphql-tools/utils" "^6" tslib "~2.0.1" -"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.3": - version "1.18.3" - resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.3.tgz#607a8bc16d80b30d59cd07d70de2ba803b57bc4a" - integrity sha512-+LVxWFlcZW+FB32CyvkdaMN/tIMajO42pCg0Cy8Z8ZZtGutXW1w6UggrvrEUzMZc9GHZQe49q+w7QQxeooaIlA== +"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.4", "@graphql-codegen/plugin-helpers@^1.18.5": + version "1.18.5" + resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.5.tgz#e1d875cfb6a2f7bf4b4318135f7fee6e1200f3b0" + integrity sha512-xY8dWdU4+mm+253esLYcKQIgWZEgI3spKPmMRQ+oAxlrCn9oIcdhhiMqNxa9rHS20xpZtlAjozxHulrqjFLuyA== dependencies: "@graphql-tools/utils" "^7.0.0" common-tags "1.8.0" import-from "3.0.0" lodash "~4.17.20" - tslib "~2.1.0" - -"@graphql-codegen/plugin-helpers@^1.18.4": - version "1.18.4" - resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.4.tgz#0adc4c0f88386a50b7a69d358080b6ee54fc3b16" - integrity sha512-dpfhUmn9GOS8ByoOPIN3V4Nn9HX7sl9NR7Hf26TgN6Clg7cQvkT6XjHdS2e56Q3kWrxZT1zJ1sEa67D3tj9ZtQ== - dependencies: - "@graphql-tools/utils" "^7.0.0" - common-tags "1.8.0" - import-from "3.0.0" - lodash "~4.17.20" - tslib "~2.1.0" + tslib "~2.2.0" "@graphql-codegen/typescript-resolvers@^1.17.7": version "1.18.2" @@ -2365,30 +2354,30 @@ tslib "~2.1.0" "@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.21.0": - version "1.21.1" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.21.1.tgz#9bce3254b8ef30a6bf64e57ba3991f9be7a19b53" - integrity sha512-JF6Vsu5HSv3dAoS2ca3PFLUN0qVxotex/+BgWw/6SKhtd83MUPnzJ/RU3lACg4vuNTCWeQSeGvg8x5qrw9Go9w== + version "1.22.0" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.22.0.tgz#d05be3a971e5d75a076a43e123b6330f4366a6ab" + integrity sha512-YzN/3MBYHrP110m8JgUWQIHt7Ivi3JXiq0RT5XNx/F9mVOSbZz6Ezbaji8YJA3y04Gl2f6ZgtdGazWANUvcOcg== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.3" - "@graphql-codegen/visitor-plugin-common" "^1.19.0" + "@graphql-codegen/plugin-helpers" "^1.18.5" + "@graphql-codegen/visitor-plugin-common" "^1.20.0" auto-bind "~4.0.0" - tslib "~2.1.0" + tslib "~2.2.0" -"@graphql-codegen/visitor-plugin-common@^1.18.3", "@graphql-codegen/visitor-plugin-common@^1.19.0": - version "1.19.0" - resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.19.0.tgz#e302dd1ba55cf220079c40fa840a355dcf81526d" - integrity sha512-Vsh9FwB90SBLnSr4KTFY8cuwjC//JBVyqn4k4usBZHFLWLkPwWzdkUKABFg6ET2gnL2L1rSU/gM30eEBrLRXFA== +"@graphql-codegen/visitor-plugin-common@^1.18.3", "@graphql-codegen/visitor-plugin-common@^1.20.0": + version "1.20.0" + resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.20.0.tgz#38d829eab7370c79aa5229190788f94adcae8f76" + integrity sha512-AYrpy8NA3DpvhDLqYGerQRv44S+YAMPKtwT8x9GNVjzP0gVfmqi3gG1bDWbP5sm6kOZKvDC0kTxGePuBSZerxw== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.3" + "@graphql-codegen/plugin-helpers" "^1.18.5" "@graphql-tools/optimize" "^1.0.1" "@graphql-tools/relay-operation-optimizer" "^6" array.prototype.flatmap "^1.2.4" auto-bind "~4.0.0" - change-case-all "^1.0.12" + change-case-all "1.0.14" dependency-graph "^0.11.0" graphql-tag "^2.11.0" parse-filepath "^1.0.2" - tslib "~2.1.0" + tslib "~2.2.0" "@graphql-modules/core@^0.7.17": version "0.7.17" @@ -2955,17 +2944,6 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@jest/types@^26.6.1": - version "26.6.1" - resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" - integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^15.0.0" - chalk "^4.0.0" - "@jest/types@^26.6.2": version "26.6.2" resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" @@ -2983,9 +2961,9 @@ integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== "@kubernetes/client-node@^0.14.0": - version "0.14.0" - resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.14.0.tgz#a02806f3b6fdb68fb51d451ee8ff01faa446f557" - integrity sha512-/37JHuEUAQ5GQ4kLKBmCYvGgf5W1KZWKreKGWFYH8VvT2Hl/o0aJZasu2w0EHEfmE11JCn0X9arVmOTyVCYvww== + version "0.14.3" + resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.14.3.tgz#5ed9b88873419080547f22cb74eb502bf6671fca" + integrity sha512-9hHGDNm2JEFQcRTpDxVoAVr0fowU+JH/l5atCXY9VXwvFM18pW5wr2LzLP+Q2Rh+uQv7Moz4gEjEKSCgVKykEQ== dependencies: "@types/js-yaml" "^3.12.1" "@types/node" "^10.12.0" @@ -3778,7 +3756,7 @@ react-transition-group "^4.0.0" rifm "^0.7.0" -"@material-ui/styles@^4.10.0", "@material-ui/styles@^4.11.3", "@material-ui/styles@^4.9.6": +"@material-ui/styles@^4.10.0", "@material-ui/styles@^4.11.0", "@material-ui/styles@^4.11.3", "@material-ui/styles@^4.9.6": version "4.11.3" resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.3.tgz#1b8d97775a4a643b53478c895e3f2a464e8916f2" integrity sha512-HzVzCG+PpgUGMUYEJ2rTEmQYeonGh41BYfILNFb/1ueqma+p1meSdu4RX6NjxYBMhf7k+jgfHFTTz+L1SXL/Zg== @@ -3831,11 +3809,54 @@ dependencies: "@types/whatwg-streams" "^0.0.7" +"@microsoft/api-documenter@^7.12.16": + version "7.12.21" + resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.12.21.tgz#cdafcd4575d42e55eb8ca338a3a5fb868f9d5872" + integrity sha512-XZQKnMppgkTHeGh92fQ7kidT2Bxr3o775Mo9gP21+kTQs3LnvukHmSnFwBTHTXpvOq1wnkbT430dRvxdayWECQ== + dependencies: + "@microsoft/api-extractor-model" "7.12.5" + "@microsoft/tsdoc" "0.12.24" + "@rushstack/node-core-library" "3.36.2" + "@rushstack/ts-command-line" "4.7.10" + colors "~1.2.1" + js-yaml "~3.13.1" + resolve "~1.17.0" + +"@microsoft/api-extractor-model@7.12.5", "@microsoft/api-extractor-model@^7.12.5", "@microsoft/api-extractor-model@workspace:*": + version "7.12.5" + resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.12.5.tgz#28d2804865ceba9cd89ab4f05cff99d16fa6c9b8" + integrity sha512-oeHZW83JWjIVoCDvdwI5nsZGPxThbq4gZTLAYNeJGZE/mKEO0iayMPGmI3EllJBjwQsFvNVU+O/HGULhB2to/g== + dependencies: + "@microsoft/tsdoc" "0.12.24" + "@rushstack/node-core-library" "3.36.2" + +"@microsoft/api-extractor@7.13.2-pr1916.0": + version "7.13.2-pr1916.0" + resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.13.2-pr1916.0.tgz#2e10cb928ea81b56cd5f63264da11f1f9b9310a5" + integrity sha512-0/HajL+NUixuNGMfFZbHmKJn5VEqiF45q2FXhu8UrggutdJ+9M6wZ++fejUHfxlC/WhQVrVVRtf4xvVM3oIW+A== + dependencies: + "@microsoft/api-extractor-model" "workspace:*" + "@microsoft/tsdoc" "0.12.24" + "@rushstack/node-core-library" "workspace:*" + "@rushstack/rig-package" "workspace:*" + "@rushstack/ts-command-line" "workspace:*" + colors "~1.2.1" + lodash "~4.17.15" + resolve "~1.17.0" + semver "~7.3.0" + source-map "~0.6.1" + typescript "~4.1.3" + "@microsoft/microsoft-graph-types@^1.25.0": version "1.25.0" resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-1.25.0.tgz#1f543ebc029a115dd1d48a1ae99d7ddd5ee9af57" integrity sha512-RsuA+ROaU3voWzG9TVBkRKxmLatteRGduFDi5p0k3FUHho49rm9SvrA7DUyYbSXLy2xXRx9AnjKM9klYBeKEiQ== +"@microsoft/tsdoc@0.12.24": + version "0.12.24" + resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.12.24.tgz#30728e34ebc90351dd3aff4e18d038eed2c3e098" + integrity sha512-Mfmij13RUTmHEMi9vRUhMXD7rnGR2VvxeNYtaGtaJ4redwwjT4UXYJ+nzmVJF7hhd4pn/Fx5sncDKxMVFJSWPg== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -4379,6 +4400,39 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@rushstack/node-core-library@3.36.2", "@rushstack/node-core-library@workspace:*": + version "3.36.2" + resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.36.2.tgz#ba00d313577f9b06d5aafaa29da0d94e594874c0" + integrity sha512-5J8xSY/PuCKR+yfxS497l0PP43kBUeD86S4eS3RzrmMle04J4522MWal8mk1T1EIDpYpgi8qScannU9oVxoStA== + dependencies: + "@types/node" "10.17.13" + colors "~1.2.1" + fs-extra "~7.0.1" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.17.0" + semver "~7.3.0" + timsort "~0.3.0" + z-schema "~3.18.3" + +"@rushstack/rig-package@workspace:*": + version "0.2.12" + resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.2.12.tgz#c434d62b28e0418a040938226f8913971d0424c7" + integrity sha512-nbePcvF8hQwv0ql9aeQxcaMPK/h1OLAC00W7fWCRWIvD2MchZOE8jumIIr66HGrfG2X1sw++m/ZYI4D+BM5ovQ== + dependencies: + resolve "~1.17.0" + strip-json-comments "~3.1.1" + +"@rushstack/ts-command-line@4.7.10", "@rushstack/ts-command-line@workspace:*": + version "4.7.10" + resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.7.10.tgz#a2ec6efb1945b79b496671ce90eb1be4f1397d31" + integrity sha512-8t042g8eerypNOEcdpxwRA3uCmz0duMo21rG4Z2mdz7JxJeylDmzjlU3wDdef2t3P1Z61JCdZB6fbm1Mh0zi7w== + dependencies: + "@types/argparse" "1.0.38" + argparse "~1.0.9" + colors "~1.2.1" + string-argv "~0.3.1" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -5589,6 +5643,11 @@ dependencies: "@types/glob" "*" +"@types/argparse@1.0.38": + version "1.0.38" + resolved "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" + integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== + "@types/aria-query@^4.2.0": version "4.2.0" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" @@ -5886,7 +5945,16 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/express-serve-static-core@*", "@types/express-serve-static-core@4.17.18", "@types/express-serve-static-core@^4.17.5": +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": + version "4.17.19" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d" + integrity sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express-serve-static-core@4.17.18": version "4.17.18" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== @@ -5902,6 +5970,14 @@ dependencies: "@types/express" "*" +"@types/express-xml-bodyparser@^0.3.2": + version "0.3.2" + resolved "https://registry.npmjs.org/@types/express-xml-bodyparser/-/express-xml-bodyparser-0.3.2.tgz#8566883271d4a28fc57b471d06e6c08047496acb" + integrity sha512-qX01S9eZI/XY48OLI7+5GnofaFG3VyBEn0oIrY5rxkq/T0U2EBpiUfM0scILUluIR6UGBHMM5C2b8Pmir3Xrtw== + dependencies: + "@types/express" "*" + "@types/xml2js" "*" + "@types/express@*", "@types/express@4.17.7", "@types/express@^4.17.6", "@types/express@^4.17.7": version "4.17.7" resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" @@ -5968,6 +6044,13 @@ dependencies: "@types/unist" "*" +"@types/highlightjs@^10.1.0": + version "10.1.0" + resolved "https://registry.npmjs.org/@types/highlightjs/-/highlightjs-10.1.0.tgz#bf16135e3c736db053f026a1fa8ff3aa247ef5c1" + integrity sha512-xCmqJdhDi4EqrfNDU5ZfZV2ejhszpWBkJS/jYCoAHZhQBBUGnE26l0AzHsZHoe37z4ZETFoZn8HKTIFDjRrfwA== + dependencies: + highlight.js "^10.1.0" + "@types/history@*": version "4.7.5" resolved "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" @@ -6087,9 +6170,9 @@ "@types/jest" "*" "@types/jest@*", "@types/jest@^26.0.7": - version "26.0.15" - resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" - integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== + version "26.0.22" + resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.22.tgz#8308a1debdf1b807aa47be2838acdcd91e88fbe6" + integrity sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw== dependencies: jest-diff "^26.0.0" pretty-format "^26.0.0" @@ -6206,10 +6289,15 @@ resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== +"@types/lunr@^2.3.3": + version "2.3.3" + resolved "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.3.tgz#ec985618fd2712c010f8edab4f1ae7784ad7c583" + integrity sha512-09sXZZVsB3Ib41U0fC+O1O+4UOZT1bl/e+/QubPxpqDWHNEchvx/DEb1KJMOwq6K3MTNzZFoNSzVdR++o1DVnw== + "@types/luxon@^1.25.0": - version "1.26.0" - resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.0.tgz#8e783986370ad3bb9f885d93eb1a91caeecaed36" - integrity sha512-zYmLYGczqBaOFaFjR1giG1QCbGMlXWOJcYgH9Mnk0MGcZHq1aer3ZwGXX8vd9NOfai6mAI/mVU6jD9hK0Wys7Q== + version "1.26.5" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.5.tgz#843fb705e16e4d2a90847a351b799ea9d879859e" + integrity sha512-XeQxxRMyJi1znfzHw4CGDLyup/raj84SnjjkI2fDootZPGlB0yqtvlvEIAmzHDa5wiEI5JJevZOWxpcofsaV+A== "@types/markdown-to-jsx@^6.11.0": version "6.11.2" @@ -6281,9 +6369,9 @@ "@types/node" "*" "@types/morgan@^1.9.0": - version "1.9.1" - resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.1.tgz#6457872df95647c1dbc6b3741e8146b71ece74bf" - integrity sha512-2j5IKrgJpEP6xw/uiVb2Xfga0W0sSVD9JP9t7EZLvpBENdB0OKgcnoKS8IsjNeNnZ/86robdZ61Orl0QCFGOXg== + version "1.9.2" + resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.2.tgz#450f958a4d3fb0694a3ba012b09c8106f9a2885e" + integrity sha512-edtGMEdit146JwwIeyQeHHg9yID4WSolQPxpEorHmN3KuytuCHyn2ELNr5Uxy8SerniFbbkmgKMrGM933am5BQ== dependencies: "@types/node" "*" @@ -6308,6 +6396,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-14.14.32.tgz#90c5c4a8d72bbbfe53033f122341343249183448" integrity sha512-/Ctrftx/zp4m8JOujM5ZhwzlWLx22nbQJiVqz8/zE15gOeEW+uly3FSX4fGFpcfEvFzXcMCJwq9lGVWgyARXhg== +"@types/node@10.17.13": + version "10.17.13" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c" + integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg== + "@types/node@^10.1.0", "@types/node@^10.12.0": version "10.17.35" resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56" @@ -6360,9 +6453,9 @@ "@types/passport-oauth2" "*" "@types/passport-google-oauth20@^2.0.3": - version "2.0.3" - resolved "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.3.tgz#f554ff6d39f395acff3f1d762e54462194dac8da" - integrity sha512-6EUEGzEg4acwowvgR/yVZIj8S2Kkwc6JmlY2/wnM1wJHNz20o7s1TIGrxnah8ymLgJasYDpy95P3TMMqlmetPw== + version "2.0.7" + resolved "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.7.tgz#0d97b7a886a0c0d2158682145cd949b29f8efe86" + integrity sha512-0HPVSqDmOWk5fRLb+bqGal+6iWsERiEco/Mli77yy5NEy22IfkoRoqZTSZ8UtXDWY9DCZlpS1Jqq56iWx2torw== dependencies: "@types/express" "*" "@types/passport" "*" @@ -6718,9 +6811,9 @@ "@types/node" "*" "@types/supertest@^2.0.8": - version "2.0.10" - resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.10.tgz#630d79b4d82c73e043e43ff777a9ca98d457cab7" - integrity sha512-Xt8TbEyZTnD5Xulw95GLMOkmjGICrOQyJ2jqgkSjAUR3mm7pAIzSR0NFBaMcwlzVvlpCjNwbATcWWwjNiZiFrQ== + version "2.0.11" + resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz#2e70f69f220bc77b4f660d72c2e1a4231f44a77d" + integrity sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q== dependencies: "@types/superagent" "*" @@ -6901,7 +6994,7 @@ dependencies: "@types/node" "*" -"@types/xml2js@^0.4.7": +"@types/xml2js@*", "@types/xml2js@^0.4.7": version "0.4.8" resolved "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.8.tgz#84c120c864a5976d0b5cf2f930a75d850fc2b03a" integrity sha512-EyvT83ezOdec7BhDaEcsklWy7RSIdi6CNe95tmOAK0yx/Lm30C9K75snT3fYayK59ApC2oyW+rcHErdG05FHJA== @@ -6933,9 +7026,9 @@ integrity sha512-/emrKCfQMQmFCqRqqBJ0JueHBT06jBRM3e8OgnvDUcvuExONujIk2hFA5dNsN9Nt41ljGVDdChvCydATZ+KOZw== "@types/yup@^0.29.8": - version "0.29.8" - resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.8.tgz#83db15735987db9fe5a38772a0fb9500e3c5bf39" - integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw== + version "0.29.11" + resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" + integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== "@types/zen-observable@^0.8.0", "@types/zen-observable@^0.8.2": version "0.8.2" @@ -7804,7 +7897,7 @@ arg@^4.1.0: resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -argparse@^1.0.10, argparse@^1.0.7: +argparse@^1.0.10, argparse@^1.0.7, argparse@~1.0.9: version "1.0.10" resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== @@ -9391,7 +9484,7 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -change-case-all@1.0.12, change-case-all@^1.0.12: +change-case-all@1.0.12: version "1.0.12" resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.12.tgz#ae3e0faf5e610e8e25c5d5eaa4a6d5c2f1d68797" integrity sha512-zdQus7R0lkprF99lrWUC5bFj6Nog4Xt4YCEjQ/vM4vbc6b5JHFBQMxRPAjfx+HJH8WxMzH0E+lQ8yQJLgmPCBg== @@ -9407,7 +9500,23 @@ change-case-all@1.0.12, change-case-all@^1.0.12: upper-case "^2.0.1" upper-case-first "^2.0.1" -change-case@^4.1.1: +change-case-all@1.0.14: + version "1.0.14" + resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.14.tgz#bac04da08ad143278d0ac3dda7eccd39280bfba1" + integrity sha512-CWVm2uT7dmSHdO/z1CXT/n47mWonyypzBbuCy5tN7uMg22BsfkhwT6oHmFCAk+gL1LOOxhdbB9SZz3J1KTY3gA== + dependencies: + change-case "^4.1.2" + is-lower-case "^2.0.2" + is-upper-case "^2.0.2" + lower-case "^2.0.2" + lower-case-first "^2.0.2" + sponge-case "^1.0.1" + swap-case "^2.0.2" + title-case "^3.0.3" + upper-case "^2.0.2" + upper-case-first "^2.0.2" + +change-case@^4.1.1, change-case@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12" integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A== @@ -9894,6 +10003,11 @@ colors@^1.1.2, colors@^1.2.1: resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== +colors@~1.2.1: + version "1.2.5" + resolved "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc" + integrity sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg== + colorspace@1.1.x: version "1.1.2" resolved "https://registry.npmjs.org/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5" @@ -9942,7 +10056,7 @@ commander@2.3.0: resolved "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873" integrity sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM= -commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: +commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.7.1, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -9990,7 +10104,7 @@ compare-versions@^3.6.0: resolved "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== -component-emitter@^1.2.1, component-emitter@^1.3.0: +component-emitter@^1.2.0, component-emitter@^1.2.1, component-emitter@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -10294,7 +10408,7 @@ cookie@^0.4.1, cookie@~0.4.1: resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== -cookiejar@^2.1.2: +cookiejar@^2.1.0, cookiejar@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== @@ -10337,9 +10451,9 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: - version "3.8.3" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" - integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== + version "3.11.0" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.11.0.tgz#05dac6aa70c0a4ad842261f8957b961d36eb8926" + integrity sha512-bd79DPpx+1Ilh9+30aT5O1sgpQd4Ttg8oqkqi51ZzhedMM1omD2e6IOF48Z/DzDCZ2svp49tN/3vneTK6ZBkXw== core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" @@ -11498,11 +11612,6 @@ diff-sequences@^24.9.0: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== -diff-sequences@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd" - integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q== - diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -12409,9 +12518,9 @@ eslint-plugin-import@^2.20.2: tsconfig-paths "^3.9.0" eslint-plugin-jest@^24.1.0: - version "24.1.5" - resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.5.tgz#1e866a9f0deac587d0a3d5d7cefe99815a580de2" - integrity sha512-FIP3lwC8EzEG+rOs1y96cOJmMVpdFNreoDJv29B5vIupVssRi8zrSY3QadogT0K3h1Y8TMxJ6ZSAzYUmFCp2hg== + version "24.3.6" + resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.3.6.tgz#5f0ca019183c3188c5ad3af8e80b41de6c8e9173" + integrity sha512-WOVH4TIaBLIeCX576rLcOgjNXqP+jNlCiEmRgFTfQtJ52DpwnIQKAVGlGPAN7CZ33bW6eNfHD6s8ZbEUTQubJg== dependencies: "@typescript-eslint/experimental-utils" "^4.0.1" @@ -12867,6 +12976,13 @@ express-session@^1.17.1: safe-buffer "5.2.0" uid-safe "~2.1.5" +express-xml-bodyparser@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/express-xml-bodyparser/-/express-xml-bodyparser-0.3.0.tgz#b1f5a98adf6c6e412c4ccba634234b82945c62be" + integrity sha1-sfWpit9sbkEsTMumNCNLgpRcYr4= + dependencies: + xml2js "^0.4.11" + express@^4.0.0, express@^4.17.0, express@^4.17.1: version "4.17.1" resolved "https://registry.npmjs.org/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -13434,7 +13550,7 @@ fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.0.5, for tapable "^1.0.0" worker-rpc "^0.1.0" -form-data@^2.3.2, form-data@^2.5.0: +form-data@^2.3.1, form-data@^2.3.2, form-data@^2.5.0: version "2.5.1" resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== @@ -13475,7 +13591,7 @@ format@^0.2.0: resolved "https://registry.npmjs.org/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs= -formidable@^1.2.2: +formidable@^1.2.0, formidable@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== @@ -13541,7 +13657,7 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^7.0.1: +fs-extra@^7.0.1, fs-extra@~7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== @@ -14663,15 +14779,20 @@ headers-utils@^1.2.0: integrity sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A== helmet@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/helmet/-/helmet-4.0.0.tgz#34c187894ed001834f997c688f2b2df19846b193" - integrity sha512-HyoRKKHhWhO6+EBfgRLkuZR4/+NXc1nJB7x0bWwW89i9eoPciK0qUqyZNOA/zowpgrW9C4+J5toqMkZrpBOlkg== + version "4.4.1" + resolved "https://registry.npmjs.org/helmet/-/helmet-4.4.1.tgz#a17e1444d81d7a83ddc6e6f9bc6e2055b994efe7" + integrity sha512-G8tp0wUMI7i8wkMk2xLcEvESg5PiCitFMYgGRc/PwULB0RVhTP5GFdxOwvJwp9XVha8CuS8mnhmE8I/8dx/pbw== hex-color-regex@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== +highlight.js@^10.1.0, highlight.js@^10.6.0: + version "10.7.2" + resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" + integrity sha512-oFLl873u4usRM9K63j4ME9u3etNF0PLiJhSQ8rdfuL51Wn3zkD6drf9ZW0dOzjnZI22YYG24z30JcmfCZjMgYg== + highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@~10.4.0: version "10.4.1" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" @@ -15184,6 +15305,11 @@ import-lazy@^2.1.0: resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= +import-lazy@~4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153" + integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw== + import-local@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" @@ -15688,7 +15814,7 @@ is-lambda@^1.0.1: resolved "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= -is-lower-case@^2.0.1: +is-lower-case@^2.0.1, is-lower-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz#1c0884d3012c841556243483aa5d522f47396d2a" integrity sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ== @@ -15948,7 +16074,7 @@ is-unicode-supported@^0.1.0: resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== -is-upper-case@^2.0.1: +is-upper-case@^2.0.1, is-upper-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" integrity sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ== @@ -16214,17 +16340,7 @@ jest-diff@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" -jest-diff@^26.0.0: - version "26.6.1" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" - integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== - dependencies: - chalk "^4.0.0" - diff-sequences "^26.5.0" - jest-get-type "^26.3.0" - pretty-format "^26.6.1" - -jest-diff@^26.6.2: +jest-diff@^26.0.0, jest-diff@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== @@ -16596,6 +16712,11 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.6.3" +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= + jmespath@0.15.0: version "0.15.0" resolved "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" @@ -16676,6 +16797,14 @@ js-yaml@^4.0.0: dependencies: argparse "^2.0.1" +js-yaml@~3.13.1: + version "3.13.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -16836,10 +16965,10 @@ json-schema@0.2.3: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.2.5: - version "0.2.5" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz#97997f50972dd0500214e208c407efa4b5d7063b" - integrity sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ== +json-schema@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.3.0.tgz#90a9c5054bd065422c00241851ce8d59475b701b" + integrity sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ== json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" @@ -17666,7 +17795,7 @@ lodash.flattendeep@^4.0.0: resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= -lodash.get@^4: +lodash.get@^4, lodash.get@^4.0.0: version "4.4.2" resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= @@ -17681,6 +17810,11 @@ lodash.isboolean@^3.0.3: resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= +lodash.isequal@^4.0.0: + version "4.5.0" + resolved "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.isinteger@^4.0.4: version "4.0.4" resolved "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" @@ -17766,7 +17900,7 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@4.x, lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.20, lodash@~4.17.4: +lodash@4.x, lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.15, lodash@~4.17.20, lodash@~4.17.4: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -17853,7 +17987,7 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" -lower-case-first@^2.0.1: +lower-case-first@^2.0.1, lower-case-first@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz#64c2324a2250bf7c37c5901e76a5b5309301160b" integrity sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg== @@ -17926,6 +18060,11 @@ lru-queue@^0.1.0: dependencies: es5-ext "~0.10.2" +lunr@^2.3.9: + version "2.3.9" + resolved "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" + integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== + luxon@1.25.0, luxon@^1.25.0: version "1.25.0" resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" @@ -18336,7 +18475,7 @@ meros@^1.1.2: resolved "https://registry.npmjs.org/meros/-/meros-1.1.4.tgz#c17994d3133db8b23807f62bec7f0cb276cfd948" integrity sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ== -methods@^1.0.0, methods@^1.1.2, methods@~1.1.2: +methods@^1.0.0, methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -18449,7 +18588,7 @@ mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.17, m dependencies: mime-db "1.46.0" -mime@1.6.0, mime@^1.4.0: +mime@1.6.0, mime@^1.4.0, mime@^1.4.1: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -21294,17 +21433,7 @@ pretty-format@^24.9.0: ansi-styles "^3.2.0" react-is "^16.8.4" -pretty-format@^26.0.0, pretty-format@^26.6.1: - version "26.6.1" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" - integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== - dependencies: - "@jest/types" "^26.6.1" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^17.0.1" - -pretty-format@^26.6.2: +pretty-format@^26.0.0, pretty-format@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== @@ -21586,6 +21715,13 @@ qs@6.7.0: resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.5.1: + version "6.10.1" + resolved "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" + integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== + dependencies: + side-channel "^1.0.4" + qs@^6.5.2, qs@^6.6.0, qs@^6.7.0, qs@^6.9.1, qs@^6.9.4, qs@^6.9.6: version "6.9.6" resolved "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee" @@ -22460,7 +22596,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -22988,6 +23124,13 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17 is-core-module "^2.2.0" path-parse "^1.0.6" +resolve@~1.17.0: + version "1.17.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + responselike@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -23401,7 +23544,7 @@ semver@7.0.0: resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@~7.3.0: version "7.3.5" resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -23452,9 +23595,9 @@ serialize-error@^2.1.0: integrity sha1-ULZ51WNc34Rme9yOWa9OW4HV9go= serialize-error@^8.0.1: - version "8.0.1" - resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-8.0.1.tgz#7a67f8ecbbf28973b5a954a2852ff9f4eef52d99" - integrity sha512-r5o60rWFS+8/b49DNAbB+GXZA0SpDpuWE758JxDKgRTga05r3U5lwyksE91dYKDhXSmnu36RALj615E6Aj5pSg== + version "8.1.0" + resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz#3a069970c712f78634942ddd50fbbc0eaebe2f67" + integrity sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ== dependencies: type-fest "^0.20.2" @@ -23640,6 +23783,15 @@ side-channel@^1.0.2: es-abstract "^1.17.0-next.1" object-inspect "^1.7.0" +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + sigmund@~1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" @@ -24025,7 +24177,7 @@ split@^1.0.0: dependencies: through "2" -sponge-case@^1.0.0: +sponge-case@^1.0.0, sponge-case@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz#260833b86453883d974f84854cdb63aecc5aef4c" integrity sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA== @@ -24151,9 +24303,9 @@ stacktrace-js@^2.0.0, stacktrace-js@^2.0.2: stacktrace-gps "^3.0.4" start-server-and-test@^1.10.11: - version "1.12.0" - resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.12.0.tgz#e836553c928a13026f79c740757d378b92bee8d6" - integrity sha512-y3M/PLUPkPBsgKoengMIMQeceT8uOnOc4bkdor/RSCK9Ih/j8z4WthSCrAboXLjgtJJWOporAiEQsnYox+THXg== + version "1.12.1" + resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.12.1.tgz#bf84eb5c5a4c8a98b93ed36519035b3f76179f0e" + integrity sha512-qGQ2HQiF2yDIfyaHsXkHfoE5UOl4zJUbJ/gx2xOkfX7iPMXW9qHmoFyaMfIDJVLNkxCK7RxSrvWEI9hNVKQluw== dependencies: bluebird "3.7.2" check-more-types "2.24.0" @@ -24161,7 +24313,7 @@ start-server-and-test@^1.10.11: execa "3.4.0" lazy-ass "1.6.0" ps-tree "1.2.0" - wait-on "5.2.1" + wait-on "5.3.0" start-server-webpack-plugin@^2.2.5: version "2.2.5" @@ -24289,7 +24441,7 @@ strict-uri-encode@^2.0.0: resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= -string-argv@0.3.1: +string-argv@0.3.1, string-argv@~0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== @@ -24515,7 +24667,7 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -24594,6 +24746,22 @@ sucrase@^3.17.1: pirates "^4.0.1" ts-interface-checker "^0.1.9" +superagent@^3.8.3: + version "3.8.3" + resolved "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" + integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.0" + debug "^3.1.0" + extend "^3.0.0" + form-data "^2.3.1" + formidable "^1.2.0" + methods "^1.1.1" + mime "^1.4.1" + qs "^6.5.1" + readable-stream "^2.3.5" + superagent@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz#09f08807bc41108ef164cfb4be293cebd480f4a6" @@ -24611,6 +24779,14 @@ superagent@^6.1.0: readable-stream "^3.6.0" semver "^7.3.2" +supertest@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36" + integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ== + dependencies: + methods "^1.1.2" + superagent "^3.8.3" + supertest@^6.1.3: version "6.1.3" resolved "https://registry.npmjs.org/supertest/-/supertest-6.1.3.tgz#3f49ea964514c206c334073e8dc4e70519c7403f" @@ -24750,20 +24926,13 @@ swagger-ui-react@^3.37.2: xml-but-prettier "^1.0.1" zenscroll "^4.0.2" -swap-case@^2.0.1: +swap-case@^2.0.1, swap-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz#671aedb3c9c137e2985ef51c51f9e98445bf70d9" integrity sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw== dependencies: tslib "^2.0.3" -swr@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/swr/-/swr-0.3.0.tgz#69602953dd50ab07fe60c920fd63199256645675" - integrity sha512-3p0p5TWH0qiaKAph5wBkMwqe2WjNseITfjmdVoNzjqRZGn/gnpRi6whMDjhMVb/vp/yyDtKWPlyjid8QZH+UhA== - dependencies: - fast-deep-equal "2.0.1" - symbol-observable@1.2.0, symbol-observable@^1.0.3, symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -25102,7 +25271,7 @@ timers-ext@^0.1.7: es5-ext "~0.10.46" next-tick "1" -timsort@^0.3.0: +timsort@^0.3.0, timsort@~0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= @@ -25132,7 +25301,7 @@ tinycolor2@^1.4.1: resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= -title-case@^3.0.2: +title-case@^3.0.2, title-case@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" integrity sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA== @@ -25448,16 +25617,21 @@ tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@~2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" - integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@~2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== tslib@~2.0.0, tslib@~2.0.1: version "2.0.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== +tslib@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -25604,7 +25778,7 @@ typescript-json-schema@^0.49.0: typescript "^4.1.3" yargs "^16.2.0" -typescript@^4.0.3, typescript@^4.1.3: +typescript@^4.0.3, typescript@^4.1.3, typescript@~4.1.3: version "4.2.3" resolved "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== @@ -26170,6 +26344,11 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= +validator@^8.0.0: + version "8.2.0" + resolved "https://registry.npmjs.org/validator/-/validator-8.2.0.tgz#3c1237290e37092355344fef78c231249dab77b9" + integrity sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -26247,14 +26426,14 @@ w3c-xmlserializer@^2.0.0: dependencies: xml-name-validator "^3.0.0" -wait-on@5.2.1: - version "5.2.1" - resolved "https://registry.npmjs.org/wait-on/-/wait-on-5.2.1.tgz#05b66fcb4d7f5da01537f03e7cf96e8836422996" - integrity sha512-H2F986kNWMU9hKlI9l/ppO6tN8ZSJd35yBljMLa1/vjzWP++Qh6aXyt77/u7ySJFZQqBtQxnvm/xgG48AObXcw== +wait-on@5.3.0: + version "5.3.0" + resolved "https://registry.npmjs.org/wait-on/-/wait-on-5.3.0.tgz#584e17d4b3fe7b46ac2b9f8e5e102c005c2776c7" + integrity sha512-DwrHrnTK+/0QFaB9a8Ol5Lna3k7WvUR4jzSKmz0YaPBpuN2sACyiPVKVfj6ejnjcajAcvn3wlbTyMIn9AZouOg== dependencies: axios "^0.21.1" joi "^17.3.0" - lodash "^4.17.20" + lodash "^4.17.21" minimist "^1.2.5" rxjs "^6.6.3" @@ -26839,7 +27018,7 @@ xml2js@0.4.19: sax ">=0.6.0" xmlbuilder "~9.0.1" -xml2js@^0.4.19, xml2js@^0.4.23: +xml2js@^0.4.11, xml2js@^0.4.19, xml2js@^0.4.23: version "0.4.23" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== @@ -26901,9 +27080,9 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + version "3.2.2" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.2.tgz#85c901bd6470ce71fc4bb723ad209b70f7f28696" + integrity sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ== y18n@^4.0.0: version "4.0.0" @@ -27084,6 +27263,17 @@ yup@^0.29.3: synchronous-promise "^2.0.13" toposort "^2.0.2" +z-schema@~3.18.3: + version "3.18.4" + resolved "https://registry.npmjs.org/z-schema/-/z-schema-3.18.4.tgz#ea8132b279533ee60be2485a02f7e3e42541a9a2" + integrity sha512-DUOKC/IhbkdLKKiV89gw9DUauTV8U/8yJl1sjf6MtDmzevLKOF2duNJ495S3MFVjqZarr+qNGCPbkg4mu4PpLw== + dependencies: + lodash.get "^4.0.0" + lodash.isequal "^4.0.0" + validator "^8.0.0" + optionalDependencies: + commander "^2.7.1" + zen-observable-ts@^0.8.21: version "0.8.21" resolved "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d"