Merge branch 'master' of github.com:spotify/backstage into mob/create-vcs-step

* 'master' of github.com:spotify/backstage: (62 commits)
  chore: ref to shadowDomRef
  chore: revert data-testid back to shadow-dom
  fix: remove re-creating new shadow-dom on page change
  fix(techdocs): cleaned up useEffect in Reader
  fix: moved useEnforcingTrailingSlash upper in the hook chain
  chore(techdocs): updated tests
  fix(techdocs): fixed race condition between page loads
  fix(useFetch): use AsyncState type
  fix(techdocs-404): throw error
  fix(techdocs-home): move docs sites mock data to array
  fix(test): change to lowercase
  fix(test): remove async
  refactor(techdocs): basic techdocs not found, split out components from reader
  fix(tests): fix fixures (#1505)
  build(deps): bump css-loader from 3.5.3 to 3.6.0
  cli: never treat success as a failure in e2e test
  Fix variable
  Use Config for Filter Org List Header
  fix(sidebar): do not let sidebar items collapse and shrink
  cli: use tree-kill to kill serve task in e2e test
  ...
This commit is contained in:
blam
2020-07-04 05:27:33 +02:00
140 changed files with 2994 additions and 662 deletions
-19
View File
@@ -43,23 +43,4 @@ jobs:
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
working-directory: ${{ runner.temp }}
run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: lint newly created app and plugin
run: yarn lint:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: test newly created app and plugin
run: yarn test:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: e2e test newly created app
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
-19
View File
@@ -44,25 +44,6 @@ jobs:
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
working-directory: ${{ runner.temp }}
run: |
sudo sysctl fs.inotify.max_user_watches=524288
node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: lint newly created app and plugin
run: yarn lint:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: test newly created app and plugin
run: yarn test:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: e2e test newly created app
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
@@ -0,0 +1,40 @@
# ADR006: Avoid React.FC and React.SFC
## Context
Facebook has removed `React.FC` from their base template for a Typescript project. The reason for this was that it was found to be an unnecessary feature with next to no benefits in combination with a few downsides.
The main reasons were:
- **children props** were implicitly added
- **Generic Type** were not supported on children
Read more about the removal in [this PR](https://github.com/facebook/create-react-app/pull/8177).
## Decision
To keep our codebase up to date, we have decided that `React.FC` and `React.SFC` should be avoided in our codebase when adding new code.
Here is an example:
```ts
/* Avoid this: */
type BadProps = { text: string; };
const BadComponent: FC<BadProps> = ({ text, children }) => (
<div>
<div>{text}</div>
{children}
</div>
)
/* Do this instead: */
type GoodProps = { text: string; children?: React.ReactNode; };
const GoodComponent = ({ text, children }: GoodProps) => (
<div>
<div>{text}</div>
{children}
</div>
)
```
## Consequences
We will gradually remove the current usage of `React.FC` and `React.SFC` from our codebase.
+27
View File
@@ -0,0 +1,27 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage
labels:
app: backstage
component: frontend
spec:
replicas: 1
selector:
matchLabels:
app: backstage
component: frontend
template:
metadata:
labels:
app: backstage
component: frontend
spec:
containers:
- name: app
image: spotify/backstage:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: app
protocol: TCP
+27
View File
@@ -0,0 +1,27 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage-backend
labels:
app: backstage
component: backend
spec:
replicas: 1
selector:
matchLabels:
app: backstage
component: backend
template:
metadata:
labels:
app: backstage
component: backend
spec:
containers:
- name: backend
image: spotify/backstage-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 7000
name: backend
protocol: TCP
+22
View File
@@ -0,0 +1,22 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v1
appVersion: "1.0"
description: A Helm chart for Spotify Backstage
name: backstage
version: 0.1.1-alpha.12
+51
View File
@@ -0,0 +1,51 @@
# Backstage Helm Chart
## App/Frontend Values
| Parameter | Description | Default |
| --------------------------- | --------------------------------------------------------- | ------------------- |
| app.enabled | Whether to render the frontend app config or not | `true` |
| app.nameOverride | Override the name given to the app/frontend | `""` |
| app.fullnameOverride | Override the full name of the app/frontend | `""` |
| app.replicaCount | The number of replicas for the app/frontend | `1` |
| app.image.repository | The image repository of the app/frontend container | `spotify/backstage` |
| app.image.tag | The app/frontend tag to pull | `latest` |
| app.image.pullPolicy | Image pull policy | `Always` |
| app.service.type | The service type for the app/frontend service | `ClusterIP` |
| app.service.port | The port for the app/frontend | `80` |
| app.ingress.enabled | Whether to create ingress or not | `false` |
| app.ingress.annotations | Annotations for the app/frontend ingress | `{}` |
| app.ingress.hosts[].host | Hostname for the app/frontend | `backstage.local` |
| app.ingress.hosts[].paths[] | Path name to serve the app/frontend on | `["/"]` |
| app.imagePullSecrets[] | Any image secrets you need to pull `app.image.repository` | `[]` |
| app.podSecurityContext | Security context for the app/frontend pods | `{}` |
| app.securityContext | Security context settings for the deployment | `{}` |
| app.resources | Kubernetes Pod resource requests/limits | `{}` |
| app.nodeSelector | Node selectors for scheduling app/frontend pods | `{}` |
| app.tolerations | Tolerations for scheduling app/frontend pods | `{}` |
| app.affinity | Affinity setttings for scheduling app/frontend pods | `{}` |
## Backend Values
| Parameter | Description | Default |
| ------------------------------- | ------------------------------------------------------------- | ------------------- |
| backend.enabled | Whether to render the backend config or not | `true` |
| backend.nameOverride | Override the name given to the backend | `""` |
| backend.fullnameOverride | Override the full name of the backend | `""` |
| backend.replicaCount | The number of replicas for the backend | `1` |
| backend.image.repository | The image repository of the backend container | `spotify/backstage` |
| backend.image.tag | The backend tag to pull | `latest` |
| backend.image.pullPolicy | Image pull policy | `Always` |
| backend.service.type | The service type for the backend service | `ClusterIP` |
| backend.service.port | The port for the backend | `80` |
| backend.ingress.enabled | Whether to create ingress or not | `false` |
| backend.ingress.annotations | Annotations for the backend ingress | `{}` |
| backend.ingress.hosts[].host | Hostname for the backend | `backstage.local` |
| backend.ingress.hosts[].paths[] | Path name to serve the backend on | `["/"]` |
| backend.imagePullSecrets[] | Any image secrets you need to pull `backend.image.repository` | `[]` |
| backend.podSecurityContext | Security context for the backend pods | `{}` |
| backend.securityContext | Security context settings for the deployment | `{}` |
| backend.resources | Kubernetes Pod resource requests/limits | `{}` |
| backend.nodeSelector | Node selectors for scheduling backend pods | `{}` |
| backend.tolerations | Tolerations for scheduling backend pods | `{}` |
| backend.affinity | Affinity setttings for scheduling backend pods | `{}` |
@@ -0,0 +1,80 @@
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "backstage.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "backstage.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "backstage.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Common App labels
*/}}
{{- define "backstage.app.labels" -}}
app.kubernetes.io/name: {{ include "backstage.name" . }}-app
helm.sh/chart: {{ include "backstage.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
Common Backend labels
*/}}
{{- define "backstage.backend.labels" -}}
app.kubernetes.io/name: {{ include "backstage.name" . }}-backend
helm.sh/chart: {{ include "backstage.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
Create the name of the service account to use for the app
*/}}
{{- define "backstage.app.serviceAccountName" -}}
{{- if .Values.app.serviceAccount.create -}}
{{ default "default" .Values.app.serviceAccount.name }}
{{- else -}}
{{ default "default" .Values.app.serviceAccount.name }}
{{- end -}}
{{- end -}}
{{/*
Create the name of the service account to use for the backend
*/}}
{{- define "backstage.backend.serviceAccountName" -}}
{{- if .Values.backend.serviceAccount.create -}}
{{ default default .Values.backend.serviceAccount.name }}
{{- else -}}
{{ default "default" .Values.backend.serviceAccount.name }}
{{- end -}}
{{- end -}}
@@ -0,0 +1,119 @@
{{- if .Values.app.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "backstage.fullname" . }}-app
labels:
{{ include "backstage.app.labels" . | indent 4 }}
spec:
replicas: {{ .Values.app.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- with .Values.app.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ template "backstage.app.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.app.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}-app
securityContext:
{{- toYaml .Values.app.securityContext | nindent 12 }}
image: "{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}"
imagePullPolicy: {{ .Values.app.image.pullPolicy }}
ports:
- name: app
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.app.resources | nindent 12 }}
{{- with .Values.app.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- if .Values.backend.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "backstage.fullname" . }}-backend
labels:
{{ include "backstage.backend.labels" . | indent 4 }}
spec:
replicas: {{ .Values.backend.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-backend
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-backend
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- with .Values.backend.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ template "backstage.backend.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.backend.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}-backend
securityContext:
{{- toYaml .Values.backend.securityContext | nindent 12 }}
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- name: backend
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: backend
readinessProbe:
httpGet:
path: /
port: backend
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- with .Values.backend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,83 @@
{{- if .Values.app.ingress.enabled -}}
{{- $fullName := include "backstage.fullname" . -}}
{{- $svcPort := .Values.app.service.port -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-app
labels:
{{ include "backstage.app.labels" . | indent 4 }}
{{- with .Values.app.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.app.ingress.tls }}
tls:
{{- range .Values.app.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.app.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
backend:
serviceName: {{ $fullName }}-app
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{ end }}
{{- if .Values.backend.ingress.enabled }}
---
{{- $fullName := include "backstage.fullname" . -}}
{{- $svcPort := .Values.backend.service.port -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }}
apiVersion: networking.k8s.io/v1beta1
{{- else }}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-backend
labels:
{{ include "backstage.backend.labels" . | indent 4 }}
{{- with .Values.backend.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.backend.ingress.tls }}
tls:
{{- range .Values.backend.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.backend.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
backend:
serviceName: {{ $fullName }}-backend
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{ end }}
@@ -0,0 +1,37 @@
{{- if .Values.app.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "backstage.fullname" . }}-app
labels:
{{ include "backstage.app.labels" . | indent 4 }}
spec:
type: {{ .Values.app.service.type }}
ports:
- port: {{ .Values.app.service.port }}
targetPort: app
protocol: TCP
name: app
selector:
app.kubernetes.io/name: {{ include "backstage.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if .Values.app.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "backstage.fullname" . }}-backend
labels:
{{ include "backstage.backend.labels" . | indent 4 }}
spec:
type: {{ .Values.backend.service.type }}
ports:
- port: {{ .Values.backend.service.port }}
targetPort: backend
protocol: TCP
name: backend
selector:
app.kubernetes.io/name: {{ include "backstage.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+95
View File
@@ -0,0 +1,95 @@
app:
enabled: true
nameOverride: ""
fullnameOverride: ""
replicaCount: 1
serviceAccount:
create: false
Name: ""
image:
repository: spotify/backstage
tag: latest
pullPolicy: Always
service:
type: ClusterIP
port: 80
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: "nginx"
hosts:
- host: backstage.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
imagePullSecrets: []
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}
backend:
enabled: false
nameOverride: ""
fullnameOverride: ""
replicaCount: 1
serviceAccount:
create: false
Name: ""
image:
repository: spotify/backstage-backend
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 7000
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: "nginx"
hosts:
- host: backstage.local
paths:
- /backend
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
imagePullSecrets: []
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}
+20
View File
@@ -0,0 +1,20 @@
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: backstage
labels:
app: backstage
component: ingress
spec:
rules:
- host: <HOSTNAME>
http:
paths:
- backend:
serviceName: backstage
servicePort: frontend
path: /
- backend:
serviceName: backstage-backend
servicePort: backend
path: /backend
+35
View File
@@ -0,0 +1,35 @@
apiVersion: v1
kind: Service
metadata:
name: backstage
labels:
app: backstage
component: frontend
spec:
type: ClusterIP
selector:
app: backstage
component: frontend
ports:
- name: frontend
port: 80
protocol: TCP
targetPort: app
---
apiVersion: v1
kind: Service
metadata:
name: backstage-backend
labels:
app: backstage
component: backend
spec:
type: ClusterIP
selector:
app: backstage
component: backend
ports:
- name: backend
port: 7000
protocol: TCP
targetPort: backend
+4 -2
View File
@@ -8,6 +8,7 @@
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/plugin-circleci": "^0.1.1-alpha.12",
"@backstage/plugin-explore": "^0.1.1-alpha.12",
"@backstage/plugin-github-actions": "^0.1.1-alpha.12",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.12",
"@backstage/plugin-graphiql": "^0.1.1-alpha.12",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.12",
@@ -21,12 +22,13 @@
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg id="favicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 337.46 428.5">
<title>
Backstage favicon
</title>
<style>
#favicon path {
fill: #121212;
}
@media (prefers-color-scheme: dark) {
#favicon path {
fill: #7df3e1;
}
}
</style>
<path d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+12 -1
View File
@@ -26,12 +26,14 @@ import {
FeatureFlags,
GoogleAuth,
GithubAuth,
OAuth2,
OktaAuth,
GitlabAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
oauth2ApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
storageApiRef,
@@ -109,7 +111,16 @@ export const apis = (config: ConfigApi) => {
builder.add(
gitlabAuthApiRef,
GitlabAuth.create({
apiOrigin: 'http://localhost:7000',
apiOrigin: backendUrl,
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
apiOrigin: backendUrl,
basePath: '/auth/',
oauthRequestApi,
}),
+1
View File
@@ -25,3 +25,4 @@ export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
export { plugin as TechDocs } from '@backstage/plugin-techdocs';
export { plugin as GraphiQL } from '@backstage/plugin-graphiql';
export { plugin as GithubActions } from '@backstage/plugin-github-actions';
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+7 -1
View File
@@ -25,7 +25,13 @@ You should only need to do this once.
After that, go to the `packages/backend` directory and run
```bash
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \
AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \
AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \
AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \
SENTRY_TOKEN=x \
LOG_LEVEL=debug \
yarn start
```
Substitute `x` for actual values, or leave them as
+1
View File
@@ -36,6 +36,7 @@ module.exports = {
rules: {
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
'error',
+1
View File
@@ -41,6 +41,7 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
'error',
+204 -31
View File
@@ -16,65 +16,238 @@
const os = require('os');
const fs = require('fs-extra');
const { resolve: resolvePath } = require('path');
const killTree = require('tree-kill');
const { resolve: resolvePath, join: joinPath } = require('path');
const Browser = require('zombie');
const {
spawnPiped,
runPlain,
handleError,
waitForPageWithText,
waitFor,
waitForExit,
print,
} = require('./helpers');
const createTestApp = require('./createTestApp');
const createTestPlugin = require('./createTestPlugin');
Browser.localhost('localhost', 3000);
async function createTempDir() {
return fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
}
async function main() {
process.env.BACKSTAGE_E2E_CLI_TEST = 'true';
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
print(`CLI E2E test root: ${rootDir}\n`);
const workDir = process.env.CI ? process.cwd() : await createTempDir();
print('Building dist workspace');
const workspaceDir = await buildDistWorkspace('workspace', rootDir);
process.stdout.write(`Initial directory: ${process.cwd()}\n`);
process.chdir(workDir);
process.stdout.write(`Working directory: ${process.cwd()}\n`);
print('Creating a Backstage App');
const appDir = await createApp('test-app', workspaceDir, rootDir);
await createTestApp();
const appDir = resolvePath(workDir, 'test-app');
process.chdir(appDir);
process.stdout.write(`App directory: ${appDir}\n`);
await createTestPlugin();
print('Creating a Backstage Plugin');
const pluginName = await createPlugin('test-plugin', appDir);
print('Starting the app');
const startApp = spawnPiped(['yarn', 'start']);
await testAppServe(pluginName, appDir);
print('All tests successful, removing test dir');
await fs.remove(rootDir);
}
/**
* Builds a dist workspace that contains the cli and core packages
*/
async function buildDistWorkspace(workspaceName, rootDir) {
const workspaceDir = resolvePath(rootDir, workspaceName);
await fs.ensureDir(workspaceDir);
print(`Preparing workspace`);
await runPlain([
'yarn',
'backstage-cli',
'build-workspace',
workspaceDir,
'@backstage/cli',
'@backstage/core',
'@backstage/dev-utils',
'@backstage/test-utils',
]);
print('Pinning yarn version in workspace');
await pinYarnVersion(workspaceDir);
print('Installing workspace dependencies');
await runPlain(['yarn', 'install', '--production', '--frozen-lockfile'], {
cwd: workspaceDir,
});
return workspaceDir;
}
/**
* Pin the yarn version in a directory to the one we're using in the Backstage repo
*/
async function pinYarnVersion(dir) {
const repoRoot = resolvePath(__dirname, '../../..');
const yarnRc = await fs.readFile(resolvePath(repoRoot, '.yarnrc'), 'utf8');
const yarnRcLines = yarnRc.split('\n');
const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarn-path'));
const [, localYarnPath] = yarnPathLine.match(/"(.*)"/);
const yarnPath = resolvePath(repoRoot, localYarnPath);
await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`);
}
/**
* Creates a new app inside rootDir called test-app, using packages from the workspaceDir
*/
async function createApp(appName, workspaceDir, rootDir) {
const child = spawnPiped(
[
'node',
resolvePath(workspaceDir, 'packages/cli/bin/backstage-cli'),
'create-app',
'--skip-install',
],
{
cwd: rootDir,
},
);
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
child.stdin.write(`${appName}\n`);
print('Waiting for app create script to be done');
await waitForExit(child);
const appDir = resolvePath(rootDir, appName);
print('Rewriting module resolutions of app to use workspace packages');
await overrideModuleResolutions(appDir, workspaceDir);
print('Pinning yarn version and registry in app');
await pinYarnVersion(appDir);
await fs.writeFile(
resolvePath(appDir, '.npmrc'),
'registry=https://registry.npmjs.org/\n',
);
print('Test app created');
for (const cmd of ['install', 'tsc', 'build', 'lint:all', 'test:all']) {
print(`Running 'yarn ${cmd}' in newly created app`);
await runPlain(['yarn', cmd], { cwd: appDir });
}
print(`Running 'yarn test:e2e:ci' in newly created app`);
await runPlain(['yarn', 'test:e2e:ci'], {
cwd: resolvePath(appDir, 'packages', 'app'),
env: {
...process.env,
APP_CONFIG_app_baseUrl: '"http://localhost:3001"',
},
});
return appDir;
} finally {
child.kill();
}
}
/**
* This points dependency resolutions into the workspace for each package that is present there
*/
async function overrideModuleResolutions(appDir, workspaceDir) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
const packageNames = await fs.readdir(resolvePath(workspaceDir, 'packages'));
for (const name of packageNames) {
const pkgPath = joinPath('..', 'workspace', 'packages', name);
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
}
fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
/**
* Uses create-plugin command to create a new plugin in the app
*/
async function createPlugin(pluginName, appDir) {
const child = spawnPiped(['yarn', 'create-plugin'], {
cwd: appDir,
});
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
child.stdin.write(`${pluginName}\n`);
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// child.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(child);
const pluginDir = resolvePath(appDir, 'plugins', pluginName);
for (const cmd of [['lint'], ['test', '--no-watch']]) {
print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`);
await runPlain(['yarn', ...cmd], { cwd: pluginDir });
}
return pluginName;
} finally {
child.kill();
}
}
/**
* Start serving the newly created app and make sure that the create plugin is rendering correctly
*/
async function testAppServe(pluginName, appDir) {
const startApp = spawnPiped(['yarn', 'start'], {
cwd: appDir,
});
Browser.localhost('localhost', 3000);
let successful = false;
try {
const browser = new Browser();
await waitForPageWithText(browser, '/', 'Welcome to Backstage');
await waitForPageWithText(
browser,
'/test-plugin',
'Welcome to test-plugin!',
`/${pluginName}`,
`Welcome to ${pluginName}!`,
);
print('Both App and Plugin loaded correctly');
successful = true;
} catch (error) {
throw new Error(`App serve test failed, ${error}`);
} finally {
startApp.kill();
// Kill entire process group, otherwise we'll end up with hanging serve processes
killTree(startApp.pid);
}
await waitForExit(startApp);
print('All tests done');
process.exit(0);
try {
await waitForExit(startApp);
} catch (error) {
if (!successful) {
throw error;
}
}
}
process.on('unhandledRejection', handleError);
-44
View File
@@ -1,44 +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.
*/
const { resolve: resolvePath } = require('path');
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestApp() {
const cliPath = resolvePath(__dirname, '../bin/backstage-cli');
print('Creating a Backstage App');
const createApp = spawnPiped(['node', cliPath, 'create-app']);
try {
let stdout = '';
createApp.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
createApp.stdin.write('test-app\n');
print('Waiting for app create script to be done');
await waitForExit(createApp);
print('Test app created');
} finally {
createApp.kill();
}
}
module.exports = createTestApp;
-44
View File
@@ -1,44 +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.
*/
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestPlugin() {
print('Creating a Backstage Plugin');
const createPlugin = spawnPiped(['yarn', 'create-plugin']);
try {
let stdout = '';
createPlugin.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
createPlugin.stdin.write('test-plugin\n');
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// createPlugin.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(createPlugin);
print('Test plugin created');
} finally {
createPlugin.kill();
}
}
module.exports = createTestPlugin;
+25 -11
View File
@@ -14,8 +14,10 @@
* limitations under the License.
*/
const childProcess = require('child_process');
const { spawn } = childProcess;
const { spawn, execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const execFile = promisify(execFileCb);
const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/;
@@ -36,24 +38,35 @@ function spawnPiped(cmd, options) {
...options,
});
child.on('error', handleError);
child.on('exit', code => {
if (code) {
print(`Child '${cmd.join(' ')}' exited with code ${code}`);
process.exit(code);
}
});
const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' ');
child.stdout.on(
'data',
pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `),
pipeWithPrefix(process.stdout, `[${logPrefix}].out: `),
);
child.stderr.on(
'data',
pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `),
pipeWithPrefix(process.stderr, `[${logPrefix}].err: `),
);
return child;
}
async function runPlain(cmd, options) {
try {
const { stdout } = await execFile(cmd[0], cmd.slice(1), {
...options,
shell: true,
});
return stdout.trim();
} catch (error) {
if (error.stderr) {
process.stderr.write(error.stderr);
}
throw error;
}
}
function handleError(err) {
process.stdout.write(`${err.name}: ${err.stack || err.message}\n`);
if (typeof err.code === 'number') {
@@ -133,7 +146,7 @@ async function waitForPageWithText(
if (findTextAttempts <= maxFindTextAttempts) {
await browser.visit(path);
await new Promise(resolve => setTimeout(resolve, intervalMs));
continue
continue;
} else {
throw error;
}
@@ -147,6 +160,7 @@ function print(msg) {
module.exports = {
spawnPiped,
runPlain,
handleError,
waitFor,
waitForExit,
+1
View File
@@ -112,6 +112,7 @@
"@types/webpack-dev-server": "^3.10.0",
"del": "^5.1.0",
"nodemon": "^2.0.2",
"tree-kill": "^1.2.2",
"ts-node": "^8.6.2",
"zombie": "^6.1.4"
},
@@ -14,18 +14,16 @@
* limitations under the License.
*/
const normalizeBaseURL = (baseURL: string): string => {
const url = new URL(baseURL);
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
return url.toString();
import fs from 'fs-extra';
import { Command } from 'commander';
import { createDistWorkspace } from '../lib/packager';
export default async (dir: string, _cmd: Command, packages: string[]) => {
if (!(await fs.pathExists(dir))) {
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
}
await createDistWorkspace(packages, {
targetDir: dir,
});
};
export default class URLParser {
constructor(public baseURL: string, public pathname: string) {
this.baseURL = normalizeBaseURL(baseURL);
}
parse(): string {
return new URL(this.pathname, this.baseURL).toString();
}
}
@@ -17,13 +17,15 @@
import fs from 'fs-extra';
import { promisify } from 'util';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import os from 'os';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { Task, templatingTask } from '../../lib/tasks';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
const exec = promisify(execCb);
async function checkExists(rootDir: string, name: string) {
@@ -39,7 +41,7 @@ async function checkExists(rootDir: string, name: string) {
});
}
export async function createTemporaryAppFolder(tempDir: string) {
async function createTemporaryAppFolder(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
@@ -70,16 +72,12 @@ async function buildApp(appDir: string) {
});
};
await installWithLocalDeps(appDir);
await runCmd('yarn install');
await runCmd('yarn tsc');
await runCmd('yarn build');
}
export async function moveApp(
tempDir: string,
destination: string,
id: string,
) {
async function moveApp(tempDir: string, destination: string, id: string) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
@@ -89,7 +87,7 @@ export async function moveApp(
});
}
export default async () => {
export default async (cmd: Command): Promise<void> => {
const questions: Question[] = [
{
type: 'input',
@@ -129,8 +127,10 @@ export default async () => {
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
Task.section('Building the app');
await buildApp(appDir);
if (!cmd.skipInstall) {
Task.section('Building the app');
await buildApp(appDir);
}
Task.log();
Task.log(
@@ -28,7 +28,8 @@ import {
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { Task, templatingTask } from '../../lib/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, id: string) {
@@ -141,9 +142,7 @@ async function cleanUp(tempDir: string) {
}
async function buildPlugin(pluginFolder: string) {
await installWithLocalDeps(paths.targetRoot);
const commands = ['yarn tsc', 'yarn build'];
const commands = ['yarn install', 'yarn tsc', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
+9
View File
@@ -25,6 +25,10 @@ const main = (argv: string[]) => {
program
.command('create-app')
.description('Creates a new app in a new directory')
.option(
'--skip-install',
'Skip the install and builds steps after creating the app',
)
.action(
lazyAction(() => import('./commands/create-app/createApp'), 'default'),
);
@@ -140,6 +144,11 @@ const main = (argv: string[]) => {
.description('Delete cache directories')
.action(lazyAction(() => import('./commands/clean/clean'), 'default'));
program
.command('build-workspace <workspace-dir> ...<packages>')
.description('Builds a temporary dist workspace from the provided packages')
.action(lazyAction(() => import('./commands/buildWorkspace'), 'default'));
program.on('command:*', () => {
console.log();
console.log(
+1 -1
View File
@@ -59,7 +59,7 @@ type Options = {
*/
export async function createDistWorkspace(
packageNames: string[],
options: Options,
options: Options = {},
) {
const targetDir =
options.targetDir ??
+1
View File
@@ -23,6 +23,7 @@ import {
import { ExitCodeError } from './errors';
import { promisify } from 'util';
import { LogFunc } from './logging';
const execFile = promisify(execFileCb);
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
+1 -106
View File
@@ -18,12 +18,8 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { resolve as resolvePath, basename, dirname } from 'path';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { promisify } from 'util';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
@@ -107,104 +103,3 @@ export async function templatingTask(
}
}
}
// List of local packages that we need to modify as a part of an E2E test
const PATCH_PACKAGES = [
'cli',
'config',
'config-loader',
'core',
'core-api',
'dev-utils',
'test-utils',
'test-utils-core',
'theme',
];
// This runs a `yarn install` task, but with special treatment for e2e tests
export async function installWithLocalDeps(dir: string) {
// This makes us install any package inside this repo as a local file dependency.
// For example, instead of trying to fetch @backstage/core from npm, we point it
// to <repo-root>/packages/core. This makes yarn use a simple file copy to install it instead.
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Linking packages locally for e2e tests');
const pkgJsonPath = resolvePath(dir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'adding',
`@backstage/${name} link to package.json`,
async () => {
const pkgPath = paths.resolveOwnRoot('packages', name);
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
await Task.forItem('executing', 'yarn install', async () => {
await exec('yarn install', { cwd: dir }).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(
`Could not execute command ${chalk.cyan('yarn install')}`,
);
});
});
// This takes care of pointing all the installed packages from this repo to
// dist instead of the local src, using the field overrides in publishConfig.
// Without this we get type checking errors in the e2e test
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Patching local dependencies for e2e tests');
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'patching',
`node_modules/@backstage/${name} package.json`,
async () => {
const depJsonPath = resolvePath(
dir,
'node_modules/@backstage',
name,
'package.json',
);
const depJson = await fs.readJson(depJsonPath);
// We want dist to be used for e2e tests
for (const key of Object.keys(depJson.publishConfig)) {
if (key !== 'access') {
depJson[key] = depJson.publishConfig[key];
}
}
await fs
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
@@ -10,11 +10,12 @@
"@backstage/core": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"history": "^5.0.0",
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -26,7 +26,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "6.0.0-alpha.5"
"react-router-dom": "6.0.0-beta.0"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
@@ -5,7 +5,7 @@
* 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
* 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,
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+1 -1
View File
@@ -36,7 +36,7 @@
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
@@ -263,3 +263,13 @@ export const gitlabAuthApiRef = createApiRef<
id: 'core.auth.gitlab',
description: 'Provides authentication towards Gitlab APIs',
});
/**
* Provides authentication for custom identity providers.
*/
export const oauth2ApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
>({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
@@ -14,7 +14,8 @@
* limitations under the License.
*/
export * from './google';
export * from './github';
export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
@@ -0,0 +1,164 @@
/*
* 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 OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import { AuthProvider, OAuthRequestApi } from '../../../definitions';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
OpenIdConnectApi,
ProfileInfo,
ProfileInfoApi,
SessionState,
SessionStateApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
type CreateOptions = {
apiOrigin: string;
basePath: string;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type OAuth2Response = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
icon: OAuth2Icon,
};
const SCOPE_PREFIX = '';
class OAuth2
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
apiOrigin,
basePath,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OAuth2Response): OAuth2Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OAuth2(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OAuth2Session>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
return new Set(scopeList);
}
}
export default OAuth2;
@@ -0,0 +1,18 @@
/*
* 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 { default as OAuth2 } from './OAuth2';
export * from './types';
@@ -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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
+1
View File
@@ -16,4 +16,5 @@
export * from './public';
import * as privateExports from './private';
export default privateExports;
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -47,8 +47,8 @@
"react-dom": "^16.12.0",
"react-helmet": "6.1.0",
"react-hook-form": "^5.7.2",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^12.2.1",
"react-use": "^14.2.0"
@@ -25,6 +25,7 @@ import {
WithStyles,
Theme,
} from '@material-ui/core';
const tableTitleCellStyles = (theme: Theme) =>
createStyles({
root: {
+3
View File
@@ -44,6 +44,9 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
'& > *': {
flexShrink: 0,
},
},
drawerOpen: {
width: sidebarConfig.drawerWidthOpen,
@@ -14,25 +14,26 @@
* limitations under the License.
*/
import React, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import Star from '@material-ui/icons/Star';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import { SidebarContext } from './config';
import {
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
googleAuthApiRef,
identityApiRef,
oauth2ApiRef,
oktaAuthApiRef,
useApi,
} from '@backstage/core-api';
import Collapse from '@material-ui/core/Collapse';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import Star from '@material-ui/icons/Star';
import React, { useContext, useEffect } from 'react';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import {
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
import { SidebarItem } from './Items';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
@@ -68,6 +69,11 @@ export function SidebarUserSettings() {
apiRef={oktaAuthApiRef}
icon={Star}
/>
<OIDCProviderSettings
title="YourOrg"
apiRef={oauth2ApiRef}
icon={Star}
/>
<SidebarItem
icon={SignOutIcon}
text="Sign Out"
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -42,8 +42,8 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0"
},
"devDependencies": {
"@types/jest": "^25.2.2",
+23 -12
View File
@@ -1,21 +1,23 @@
import {
ApiRegistry,
AlertApiForwarder,
alertApiRef,
ApiRegistry,
ErrorAlerter,
ErrorApiForwarder,
errorApiRef,
GithubAuth,
githubAuthApiRef,
GitlabAuth,
gitlabAuthApiRef,
GoogleAuth,
googleAuthApiRef,
identityApiRef,
OAuth2,
oauth2ApiRef,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
oktaAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
GitlabAuth,
OktaAuth,
identityApiRef,
oktaAuthApiRef,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -72,4 +74,13 @@ builder.add(
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export const apis = builder.build();
+2 -2
View File
@@ -40,8 +40,8 @@
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
@@ -25,6 +25,7 @@ import privateExports, {
import { RenderResult } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils-core';
import { createMockApiRegistry } from './mockApiRegistry';
const { PrivateAppImpl } = privateExports;
const NotFoundErrorPage = () => {
@@ -15,14 +15,15 @@
*/
import Router from 'express-promise-router';
import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createGitlabProvider } from './gitlab';
import { createSamlProvider } from './saml';
import { createOktaProvider } from './okta';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { createGithubProvider } from './github';
import { createGitlabProvider } from './gitlab';
import { createGoogleProvider } from './google';
import { createOAuth2Provider } from './oauth2';
import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import { AuthProviderConfig, AuthProviderFactory } from './types';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -30,6 +31,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
gitlab: createGitlabProvider,
saml: createSamlProvider,
okta: createOktaProvider,
oauth2: createOAuth2Provider,
};
export const createAuthProviderRouter = (
@@ -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 { createOAuth2Provider } from './provider';
@@ -0,0 +1,198 @@
/*
* 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 passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
EnvironmentProviderConfig,
GenericOAuth2ProviderConfig,
GenericOAuth2ProviderOptions,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
} from '../types';
type PrivateInfo = {
refreshToken: string;
};
export class OAuth2AuthProvider implements OAuthProviderHandlers {
private readonly _strategy: OAuth2Strategy;
constructor(options: GenericOAuth2ProviderOptions) {
this._strategy = new OAuth2Strategy(
{ ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
params: any,
rawProfile: passport.Profile,
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile = makeProfileInfo(rawProfile, params.id_token);
done(
undefined,
{
providerInfo: {
idToken: params.id_token,
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
},
profile,
},
{
refreshToken,
},
);
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, this._strategy);
return {
response: await this.populateIdentity(response),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const profile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
params.id_token,
);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Profile does not contain a profile');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export function createOAuth2Provider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as GenericOAuth2ProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/oauth2/handler/frame${callbackURLParam}`,
authorizationURL: config.authorizationURL,
tokenURL: config.tokenURL,
};
if (
!opts.clientID ||
!opts.clientSecret ||
!opts.authorizationURL ||
!opts.tokenURL
) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars',
);
}
logger.warn(
'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), {
disableRefresh: false,
providerId: 'oauth2',
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -33,6 +33,11 @@ export type OAuthProviderOptions = {
callbackURL: string;
};
export type GenericOAuth2ProviderOptions = OAuthProviderOptions & {
authorizationURL: string;
tokenURL: string;
};
export type OAuthProviderConfig = {
/**
* Cookies can be marked with a secure flag to send cookies only when the request
@@ -61,6 +66,11 @@ export type OAuthProviderConfig = {
audience?: string;
};
export type GenericOAuth2ProviderConfig = OAuthProviderConfig & {
authorizationURL: string;
tokenURL: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
@@ -100,6 +100,16 @@ export async function createRouter(
audience: process.env.AUTH_OKTA_AUDIENCE,
},
},
oauth2: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_OAUTH2_CLIENT_ID!,
clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!,
authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!,
tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!,
},
},
},
},
};
+1
View File
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+2 -2
View File
@@ -32,8 +32,8 @@
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
@@ -18,6 +18,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { Entity } from '@backstage/catalog-model';
const server = setupServer();
describe('CatalogClient', () => {
@@ -19,6 +19,7 @@ import {
ContentHeader,
identityApiRef,
SupportButton,
configApiRef,
useApi,
} from '@backstage/core';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
@@ -51,6 +52,8 @@ const CatalogPageContents = () => {
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const orgName =
useApi(configApiRef).getOptionalString('organization.name') ?? 'Company';
const tabs = useMemo<LabeledComponentType[]>(
() => [
@@ -98,7 +101,7 @@ const CatalogPageContents = () => {
],
},
{
name: 'Company', // TODO: Replace with Company name, read from app config.
name: orgName,
items: [
{
id: 'all',
@@ -108,7 +111,7 @@ const CatalogPageContents = () => {
],
},
],
[isStarredEntity, userId],
[isStarredEntity, userId, orgName],
);
return (
@@ -18,14 +18,16 @@ import { Table, TableColumn, TableProps } from '@backstage/core';
import { Link } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { entityRoute } from '../../routes';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
} from '../FavouriteEntity/FavouriteEntity';
const columns: TableColumn<Entity>[] = [
{
@@ -125,13 +127,8 @@ export const CatalogTable = ({
const isStarred = isStarredEntity(rowData);
return {
cellStyle: { paddingLeft: '1em' },
icon: () =>
isStarred ? (
<Star htmlColor="#f3ba37" fontSize="small" />
) : (
<StarOutline fontSize="small" />
),
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
icon: () => favouriteEntityIcon(isStarred),
tooltip: favouriteEntityTooltip(isStarred),
onClick: () => toggleStarredEntity(rowData),
};
},
@@ -28,7 +28,7 @@ import {
useApi,
} from '@backstage/core';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import { Grid, Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -37,6 +37,7 @@ import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
const REDIRECT_DELAY = 1000;
function headerProps(
@@ -63,6 +64,16 @@ export const getPageTheme = (entity?: Entity): PageTheme => {
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
entity,
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
export const EntityPage: FC<{}> = () => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
@@ -138,7 +149,11 @@ export const EntityPage: FC<{}> = () => {
return (
<Page theme={getPageTheme(entity)}>
<Header title={headerTitle} type={headerType}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
@@ -0,0 +1,56 @@
/*
* 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 } from 'react';
import { IconButton, Tooltip, withStyles } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { Entity } from '@backstage/catalog-model';
type Props = ComponentProps<typeof IconButton> & { entity: Entity };
const YellowStar = withStyles({
root: {
color: '#f3ba37',
},
})(Star);
export const favouriteEntityTooltip = (isStarred: boolean) =>
isStarred ? 'Remove from favorites' : 'Add to favorites';
export const favouriteEntityIcon = (isStarred: boolean) =>
isStarred ? <YellowStar /> : <StarBorder />;
/**
* 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 FavouriteEntity: React.FC<Props> = props => {
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const isStarred = isStarredEntity(props.entity);
return (
<IconButton
color="inherit"
{...props}
onClick={() => toggleStarredEntity(props.entity)}
>
<Tooltip title={favouriteEntityTooltip(isStarred)}>
{favouriteEntityIcon(isStarred)}
</Tooltip>
</IconButton>
);
};
+2 -2
View File
@@ -40,8 +40,8 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom/extend-expect';
require('jest-fetch-mock').enableMocks();
+1
View File
@@ -16,6 +16,7 @@
import React, { FC, useReducer, Dispatch, Reducer } from 'react';
import { circleCIApiRef } from '../api';
import type { State, Action, SettingsState } from './types';
export type { SettingsState };
export const AppContext = React.createContext<[State, Dispatch<Action>]>(
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# github-actions
Welcome to the github-actions plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/github-actions](http://localhost:3000/github-actions).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-github-actions",
"version": "0.1.1-alpha.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"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/core": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/dev-utils": "^0.1.1-alpha.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
]
}
@@ -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.
*/
import { Build, BuildDetails, BuildStatus } from './types';
export class BuildsClient {
static create(): BuildsClient {
return new BuildsClient();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async listBuilds(_entityUri: string): Promise<Build[]> {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getBuild(_buildUri: string): Promise<BuildDetails> {
return {
build: {
commitId: 'TODO',
branch: 'TODO',
uri: 'TODO',
status: BuildStatus.Running,
message: 'TODO',
},
author: 'TODO',
logUrl: 'TODO',
overviewUrl: 'TODO',
};
}
}
@@ -0,0 +1,18 @@
/*
* 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 { BuildsClient } from './BuildsClient';
export * from './types';
@@ -0,0 +1,38 @@
/*
* 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 enum BuildStatus {
Null,
Success,
Failure,
Pending,
Running,
}
export type Build = {
commitId: string;
message: string;
branch: string;
status: BuildStatus;
uri: string;
};
export type BuildDetails = {
build: Build;
author: string;
logUrl: string;
overviewUrl: string;
};
@@ -0,0 +1,143 @@
/*
* 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 { Link } from '@backstage/core';
import {
Button,
ButtonGroup,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
}));
const client = BuildsClient.create();
export const BuildDetailsPage = () => {
const classes = useStyles();
const { buildUri } = useParams();
const status = useAsync(() => client.getBuild(buildUri), [buildUri]);
if (status.loading) {
return <LinearProgress />;
} else if (status.error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {status.error.message}
</Typography>
);
}
const details = status.value;
return (
<div className={classes.root}>
<Typography className={classes.title} variant="h3">
<Link to="/builds">
<Typography component="span" variant="h3" color="primary">
&lt;
</Typography>
</Link>
Build Details
</Typography>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Branch</Typography>
</TableCell>
<TableCell>{details?.build.branch}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>{details?.build.message}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{details?.build.commitId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<BuildStatusIndicator status={details?.build.status} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Author</Typography>
</TableCell>
<TableCell>{details?.author}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
<ButtonGroup
variant="text"
color="primary"
aria-label="text primary button group"
>
{details?.overviewUrl && (
<Button>
<Link to={details.overviewUrl}>GitHub</Link>
</Button>
)}
{details?.logUrl && (
<Button>
<Link to={details.logUrl}>Logs</Link>
</Button>
)}
</ButtonGroup>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
);
};
@@ -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 { BuildDetailsPage } from './BuildDetailsPage';
@@ -0,0 +1,102 @@
/*
* 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 { Link } from '@backstage/core';
import {
LinearProgress,
makeStyles,
Table,
TableBody,
TableCell,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const client = BuildsClient.create();
const useStyles = makeStyles<Theme>(theme => ({
root: {
// height: 400,
},
title: {
paddingBottom: theme.spacing(1),
},
}));
export const BuildInfoCard = () => {
const classes = useStyles();
const status = useAsync(() => client.listBuilds('entity:spotify:backstage'));
let content: JSX.Element;
if (status.loading) {
content = <LinearProgress />;
} else if (status.error) {
content = (
<Typography variant="h2" color="error">
Failed to load builds, {status.error.message}
</Typography>
);
} else {
const [build] =
status.value?.filter(({ branch }) => branch === 'master') ?? [];
content = (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>
<Link to={`builds/${encodeURIComponent(build?.uri || '')}`}>
<Typography color="primary">{build?.message}</Typography>
</Link>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{build?.commitId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<BuildStatusIndicator status={build?.status} />
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
return (
<div className={classes.root}>
<Typography variant="h2" className={classes.title}>
Master Build
</Typography>
{content}
</div>
);
};
@@ -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 { BuildInfoCard } from './BuildInfoCard';
@@ -0,0 +1,128 @@
/*
* 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 { Link } from '@backstage/core';
import {
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Theme,
Tooltip,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const client = BuildsClient.create();
const LongText = ({ text, max }: { text: string; max: number }) => {
if (text.length < max) {
return <span>{text}</span>;
}
return (
<Tooltip title={text}>
<span>{text.slice(0, max)}...</span>
</Tooltip>
);
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
padding: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
}));
const PageContents = () => {
const { loading, error, value } = useAsync(() =>
client.listBuilds('entity:spotify:backstage'),
);
if (loading) {
return <LinearProgress />;
}
if (error) {
return (
<Typography variant="h2" color="error">
Failed to load builds, {error.message}{' '}
</Typography>
);
}
return (
<TableContainer component={Paper}>
<Table aria-label="CI/CD builds table">
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Branch</TableCell>
<TableCell>Message</TableCell>
<TableCell>Commit</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value!.map(build => (
<TableRow key={build.uri}>
<TableCell>
<BuildStatusIndicator status={build.status} />
</TableCell>
<TableCell>
<Typography>
<LongText text={build.branch} max={30} />
</Typography>
</TableCell>
<TableCell>
<Link to={`builds/${encodeURIComponent(build.uri)}`}>
<Typography color="primary">
<LongText text={build.message} max={60} />
</Typography>
</Link>
</TableCell>
<TableCell>
<Tooltip title={build.commitId}>
<Typography noWrap>{build.commitId.slice(0, 10)}</Typography>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export const BuildListPage = () => {
const classes = useStyles();
return (
<div className={classes.root}>
<Typography variant="h3" className={classes.title}>
CI/CD Builds
</Typography>
<PageContents />
</div>
);
};
@@ -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 { BuildListPage } from './BuildListPage';
@@ -0,0 +1,74 @@
/*
* 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 { IconComponent } from '@backstage/core';
import { makeStyles, Theme } from '@material-ui/core';
import ProgressIcon from '@material-ui/icons/Autorenew';
import SuccessIcon from '@material-ui/icons/CheckCircle';
import FailureIcon from '@material-ui/icons/Error';
import UnknownIcon from '@material-ui/icons/Help';
import React from 'react';
import { BuildStatus } from '../../apis/builds';
type Props = {
status?: BuildStatus;
};
type StatusStyle = {
icon: IconComponent;
color: string;
};
const styles: { [key in BuildStatus]: StatusStyle } = {
[BuildStatus.Null]: {
icon: UnknownIcon,
color: '#f49b20',
},
[BuildStatus.Success]: {
icon: SuccessIcon,
color: '#1db855',
},
[BuildStatus.Failure]: {
icon: FailureIcon,
color: '#CA001B',
},
[BuildStatus.Pending]: {
icon: UnknownIcon,
color: '#5BC0DE',
},
[BuildStatus.Running]: {
icon: ProgressIcon,
color: '#BEBEBE',
},
};
const useStyles = makeStyles<Theme, StatusStyle>({
icon: style => ({
color: style.color,
}),
});
export const BuildStatusIndicator = ({ status }: Props) => {
const style = (status && styles[status]) || styles[BuildStatus.Null];
const classes = useStyles(style);
const Icon = style.icon;
return (
<div className={classes.icon}>
<Icon />
</div>
);
};
@@ -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 { BuildStatusIndicator } from './BuildStatusIndicator';
+17
View File
@@ -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 { plugin } from './plugin';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('github-actions', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+37
View File
@@ -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.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { BuildDetailsPage } from './components/BuildDetailsPage';
import { BuildListPage } from './components/BuildListPage';
// TODO(freben): This is just a demo route for now
export const rootRouteRef = createRouteRef({
path: '/github-actions',
title: 'GitHub Actions',
});
export const buildRouteRef = createRouteRef({
path: '/github-actions/builds/:buildUri',
title: 'GitHub Actions Build',
});
export const plugin = createPlugin({
id: 'github-actions',
register({ router }) {
router.addRoute(rootRouteRef, BuildListPage);
router.addRoute(buildRouteRef, BuildDetailsPage);
},
});
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+1 -1
View File
@@ -28,7 +28,7 @@
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();

Some files were not shown because too many files have changed in this diff Show More