diff --git a/.changeset/2798.md b/.changeset/2798.md
new file mode 100644
index 0000000000..2b202142ef
--- /dev/null
+++ b/.changeset/2798.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-catalog-backend': minor
+---
+
+Use the new `UrlReader` in `PlaceholderProcessor`.
+This allows to use the placeholder processor to include API definitions in API entities.
+Previously it was only possible to do this if the definition comes from the same location type as the entity itself.
diff --git a/.changeset/2803.md b/.changeset/2803.md
new file mode 100644
index 0000000000..3a4773757f
--- /dev/null
+++ b/.changeset/2803.md
@@ -0,0 +1,26 @@
+---
+'@backstage/plugin-catalog-backend': minor
+---
+
+Remove the backstage.io/definition-at-location annotation.
+The annotation was superseded by the placeholder processor.
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: API
+metadata:
+ name: spotify
+ description: The Spotify web API
+ tags:
+ - spotify
+ - rest
+ annotations:
+ # Don't use this annotation, but the placeholder $text instead (see below).
+ backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml'
+spec:
+ type: openapi
+ lifecycle: production
+ owner: spotify@example.com
+ definition:
+ $text: https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml
+```
diff --git a/.changeset/2804.md b/.changeset/2804.md
new file mode 100644
index 0000000000..5223a24791
--- /dev/null
+++ b/.changeset/2804.md
@@ -0,0 +1,15 @@
+---
+'@backstage/plugin-proxy-backend': minor
+---
+
+Limit the http headers that are forwarded from the request to a safe set of defaults.
+A user can configure additional headers that should be forwarded if the specific applications needs that.
+
+```yaml
+proxy:
+ '/my-api':
+ target: 'https://my-api.com/get'
+ allowedHeaders:
+ # We need to forward the Authorization header that was provided by the caller
+ - Authorization
+```
diff --git a/.changeset/codeowner-processor-url-reader.md b/.changeset/codeowner-processor-url-reader.md
new file mode 100644
index 0000000000..af4eb0997f
--- /dev/null
+++ b/.changeset/codeowner-processor-url-reader.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': minor
+---
+
+Use the new `UrlReader` in the `CodeOwnersProcessor`.
diff --git a/.changeset/cyan-plants-dress.md b/.changeset/cyan-plants-dress.md
new file mode 100644
index 0000000000..19ebc63e53
--- /dev/null
+++ b/.changeset/cyan-plants-dress.md
@@ -0,0 +1,5 @@
+---
+'example-backend': patch
+---
+
+Pass GitHub token into Scaffolder GitHub Preparer
diff --git a/.changeset/long-ways-count.md b/.changeset/long-ways-count.md
new file mode 100644
index 0000000000..e04c1225cc
--- /dev/null
+++ b/.changeset/long-ways-count.md
@@ -0,0 +1,57 @@
+---
+'@backstage/plugin-techdocs-backend': minor
+'@backstage/create-app': patch
+---
+
+Updated naming of environment variables. New pattern [NAME]\_TOKEN for Github, Gitlab, Azure & Github enterprise access tokens.
+
+### Detail:
+
+- Previously we have to export same token for both, catalog & scaffolder
+
+```bash
+export GITHUB_ACCESS_TOKEN=foo
+export GITHUB_PRIVATE_TOKEN=foo
+```
+
+with latest changes, only single export is sufficient.
+
+```bash
+export GITHUB_TOKEN=foo
+export GITLAB_TOKEN=foo
+export GHE_TOKEN=foo
+export AZURE_TOKEN=foo
+```
+
+### list:
+
+
+
+ Old name
+ New name
+
+
+ GITHUB_ACCESS_TOKEN
+ GITHUB_TOKEN
+
+
+ GITHUB_PRIVATE_TOKEN
+ GITHUB_TOKEN
+
+
+ GITLAB_ACCESS_TOKEN
+ GITLAB_TOKEN
+
+
+ GITLAB_PRIVATE_TOKEN
+ GITLAB_TOKEN
+
+
+ AZURE_PRIVATE_TOKEN
+ AZURE_TOKEN
+
+
+ GHE_PRIVATE_TOKEN
+ GHE_TOKEN
+
+
diff --git a/.changeset/loud-lamps-visit.md b/.changeset/loud-lamps-visit.md
new file mode 100644
index 0000000000..9023061643
--- /dev/null
+++ b/.changeset/loud-lamps-visit.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Including source maps with all packages
diff --git a/.changeset/ninety-pens-poke.md b/.changeset/ninety-pens-poke.md
new file mode 100644
index 0000000000..94bcb06749
--- /dev/null
+++ b/.changeset/ninety-pens-poke.md
@@ -0,0 +1,5 @@
+---
+'@backstage/create-app': patch
+---
+
+Fix for configured templates using 'url' locations even though it's not supported yet
diff --git a/.changeset/pretty-cups-joke.md b/.changeset/pretty-cups-joke.md
new file mode 100644
index 0000000000..49a1db2a75
--- /dev/null
+++ b/.changeset/pretty-cups-joke.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Add authentication token to Scaffolder GitHub Preparer
diff --git a/.changeset/remove-location-processor-read.md b/.changeset/remove-location-processor-read.md
new file mode 100644
index 0000000000..78aea6d820
--- /dev/null
+++ b/.changeset/remove-location-processor-read.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-catalog-backend': minor
+---
+
+Remove the `read` argument of `LocationProcessor.processEntity`.
+Instead, pass the `UrlReader` into the constructor of your `LocationProcessor`.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 0437698301..01fc8e70c3 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -13,3 +13,4 @@
- [ ] Prettier run on changed files
- [ ] Tests added for new functionality
- [ ] Regression tests added for bug fixes
+- [ ] Added a changeset ([more info](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md#creating-changesets))
diff --git a/.codecov.yml b/.github/codecov.yml
similarity index 100%
rename from .codecov.yml
rename to .github/codecov.yml
diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt
index 21045f04dd..c9774ae237 100644
--- a/.github/styles/vocab.txt
+++ b/.github/styles/vocab.txt
@@ -32,6 +32,7 @@ Codehilite
codeowners
config
Config
+configmaps
configs
const
cookiecutter
@@ -67,6 +68,7 @@ graphviz
Hackathons
haproxy
heroku
+horizontalpodautoscalers
Hostname
http
https
@@ -140,6 +142,7 @@ rankdir
readme
Readme
Redash
+replicasets
repo
Repo
repos
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1860a4de6f..487b94a39d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re
> Collect changes for the next release below
+### Backend (example-backend, or backends created with @backstage/create-app)
+
+- A plugin database manager has been created, and plugins can now accept that interface as an argument during initialisation. Notably, the `auth` plugin has a [`createRouter` signature change](./plugins/auth-backend/src/service/router.ts). See [packages/backend/src/index.ts](./packages/backend/src/index.ts) on how to set it up. [#2697](https://github.com/spotify/backstage/pull/2697)
+
## v0.1.1-alpha.24
### Backend (example-backend, or backends created with @backstage/create-app)
diff --git a/app-config.yaml b/app-config.yaml
index d670771a31..228eafc588 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -67,21 +67,21 @@ integrations:
github:
- host: github.com
token:
- $env: GITHUB_PRIVATE_TOKEN
+ $env: GITHUB_TOKEN
### Example for how to add your GitHub Enterprise instance using the API:
# - host: ghe.example.net
# apiBaseUrl: https://ghe.example.net/api/v3
# token:
- # $env: GHE_PRIVATE_TOKEN
+ # $env: GHE_TOKEN
### Example for how to add your GitHub Enterprise instance using raw HTTP fetches (token is optional):
# - host: ghe.example.net
# rawBaseUrl: https://ghe.example.net/raw
# token:
- # $env: GHE_PRIVATE_TOKEN
+ # $env: GHE_TOKEN
gitlab:
- host: gitlab.com
token:
- $env: GITLAB_PRIVATE_TOKEN
+ $env: GITLAB_TOKEN
bitbucket:
- host: bitbucket.org
username:
@@ -91,7 +91,7 @@ integrations:
azure:
- host: dev.azure.com
token:
- $env: AZURE_PRIVATE_TOKEN
+ $env: AZURE_TOKEN
catalog:
rules:
@@ -102,12 +102,12 @@ catalog:
providers:
- target: https://github.com
token:
- $env: GITHUB_PRIVATE_TOKEN
+ $env: GITHUB_TOKEN
#### Example for how to add your GitHub Enterprise instance using the API:
# - target: https://ghe.example.net
# apiBaseUrl: https://ghe.example.net/api/v3
# token:
- # $env: GHE_PRIVATE_TOKEN
+ # $env: GHE_TOKEN
ldapOrg:
### Example for how to add your enterprise LDAP server
# providers:
@@ -146,18 +146,18 @@ catalog:
scaffolder:
github:
token:
- $env: GITHUB_ACCESS_TOKEN
+ $env: GITHUB_TOKEN
visibility: public # or 'internal' or 'private'
gitlab:
api:
baseUrl: https://gitlab.com
token:
- $env: GITLAB_ACCESS_TOKEN
+ $env: GITLAB_TOKEN
azure:
baseUrl: https://dev.azure.com/{your-organization}
api:
token:
- $env: AZURE_PRIVATE_TOKEN
+ $env: AZURE_TOKEN
auth:
providers:
diff --git a/backstage_overview.png b/backstage_overview.png
deleted file mode 100644
index ae32ae6628..0000000000
Binary files a/backstage_overview.png and /dev/null differ
diff --git a/contrib/docker/kubernetes-example-backend/Dockerfile b/contrib/docker/kubernetes-example-backend/Dockerfile
new file mode 100644
index 0000000000..df617decf5
--- /dev/null
+++ b/contrib/docker/kubernetes-example-backend/Dockerfile
@@ -0,0 +1,35 @@
+FROM node:12-buster
+
+WORKDIR /usr/src/app
+
+# (workaround) Install cookiecutter and mkdocs to avoid the need to run docker in docker
+RUN cd /tmp && curl -O https://www.python.org/ftp/python/3.8.2/Python-3.8.2.tar.xz && \
+ tar -xvf Python-3.8.2.tar.xz && \
+ cd Python-3.8.2 && \
+ ./configure --enable-optimizations && \
+ make -j 4 && \
+ make altinstall
+
+RUN apt update
+RUN apt install -y mkdocs
+
+RUN pip3.8 install mkdocs-techdocs-core
+
+RUN pip3.8 install cookiecutter && \
+ apt remove -y build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev libssl-dev libsqlite3-dev libreadline-dev libffi-dev libbz2-dev g++ python-pip python-dev && \
+ rm -rf /var/cache/apt/* /tmp/Python-3.8.2
+
+# Copy repo skeleton first, to avoid unnecessary docker cache invalidation.
+# The skeleton contains the package.json of each package in the monorepo,
+# and along with yarn.lock and the root package.json, that's enough to run yarn install.
+ADD yarn.lock package.json skeleton.tar ./
+
+RUN yarn install --frozen-lockfile --production
+
+# This will copy the contents of the dist-workspace when running the build-image command.
+# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
+COPY . .
+
+CMD ["node", "packages/backend"]
+
+
diff --git a/contrib/docker/kubernetes-example-backend/README.md b/contrib/docker/kubernetes-example-backend/README.md
new file mode 100644
index 0000000000..d0f9d57022
--- /dev/null
+++ b/contrib/docker/kubernetes-example-backend/README.md
@@ -0,0 +1,13 @@
+# Example backend Dockerfile
+
+This Dockerfile will build the example backend with certain additional binaries needed to workaround
+the docker requirement in the scaffolder and techdocs.
+
+# Usage
+
+```bash
+yarn docker-build -f --tag
+```
+
+> The absolute path is necessary as this directory is not copied to the build workspace when building
+> the docker image.
diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index 757e0461a9..40a4ca3ea1 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -41,27 +41,6 @@ expecting a two-item array out of it. The format of the target part is
type-dependent and could conceivably even be an empty string, but the separator
colon is always present.
-### backstage.io/definition-at-location
-
-```yaml
-# Example
-apiVersion: backstage.io/v1alpha1
-kind: API
-metadata:
- name: petstore
- annotations:
- backstage.io/definition-at-location: 'url:https://petstore.swagger.io/v2/swagger.json'
-spec:
- type: openapi
-```
-
-This annotation allows to fetch an API definition from another location, instead
-of wrapping the API definition inside the definition field. This allows to
-easily consume existing API definition. The definition is fetched during
-ingestion by a processor and included in the entity. It is updated on every
-refresh. The annotation contains a location reference string that contains the
-location processor type and the target.
-
### backstage.io/techdocs-ref
```yaml
@@ -205,6 +184,25 @@ This annotation was used for a while to enable the GitHub Actions feature. This
is now instead using the [github.com/project-slug](#github-com-project-slug)
annotation, with the same value format.
+### backstage.io/definition-at-location
+
+This annotation allowed to load the API definition from another location. Now
+placeholders can be used instead:
+
+```
+apiVersion: backstage.io/v1alpha1
+kind: API
+metadata:
+ name: petstore
+ description: The Petstore API
+spec:
+ type: openapi
+ lifecycle: production
+ owner: petstore@example.com
+ definition:
+ $text: https://petstore.swagger.io/v2/swagger.json
+```
+
## Links
- [Descriptor Format: annotations](descriptor-format.md#annotations-optional)
diff --git a/docs/features/software-templates/extending/create-your-own-publisher.md b/docs/features/software-templates/extending/create-your-own-publisher.md
index ecb097f9d9..546980f033 100644
--- a/docs/features/software-templates/extending/create-your-own-publisher.md
+++ b/docs/features/software-templates/extending/create-your-own-publisher.md
@@ -34,7 +34,7 @@ import { Octokit } from '@octokit/rest';
import type { PluginEnvironment } from '../types';
export default async function createPlugin({ logger }: PluginEnvironment) {
- const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN });
+ const githubClient = new Octokit({ auth: process.env.GITHUB_TOKEN });
const publisher = new GithubPublisher({ client: githubClient });
return await createRouter({
diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md
index 60f73ad0c3..838c5e2d11 100644
--- a/docs/features/software-templates/installation.md
+++ b/docs/features/software-templates/installation.md
@@ -232,13 +232,13 @@ instance:
scaffolder:
github:
token:
- $env: GITHUB_ACCESS_TOKEN
+ $env: GITHUB_TOKEN
visibility: public # or 'internal' or 'private'
gitlab:
api:
baseUrl: https://gitlab.com
token:
- $env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN
+ $env: GITLAB_TOKEN
```
#### Azure DevOps
@@ -255,7 +255,7 @@ scaffolder:
baseUrl: https://dev.azure.com/{your-organization}
api:
token:
- $env: AZURE_PRIVATE_TOKEN
+ $env: AZURE_TOKEN
```
### Running the Backend
@@ -265,7 +265,7 @@ backend with the new configuration:
```bash
cd packages/backend
-GITHUB_ACCESS_TOKEN= yarn start
+GITHUB_TOKEN= yarn start
```
If you've also set up the frontend plugin, so you should be ready to go browse
diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md
index 3d9aa90447..f55b4d9229 100644
--- a/docs/plugins/proxying.md
+++ b/docs/plugins/proxying.md
@@ -57,6 +57,15 @@ is also possible to limit the forwarded HTTP methods with the configuration
`allowedMethods`, for example `allowedMethods: ['GET']` to enforce read-only
access.
+By default, the proxy will only forward safe HTTP request headers to the target.
+Those are based on the headers that are considered safe for CORS and includes
+headers like `content-type` or `last-modified`, as well as all headers that are
+set by the proxy. If the proxy should forward other headers like
+`authorization`, this must be enabled by the `allowedHeaders` config, for
+example `allowedHeaders: ['Authorization']`. This should help to not
+accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to
+third-parties.
+
If the value is a string, it is assumed to correspond to:
```yaml
diff --git a/packages/app/package.json b/packages/app/package.json
index 20b9e24701..76e20bc0dc 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -28,6 +28,7 @@
"@backstage/plugin-tech-radar": "^0.1.1-alpha.24",
"@backstage/plugin-techdocs": "^0.1.1-alpha.24",
"@backstage/plugin-welcome": "^0.1.1-alpha.24",
+ "@backstage/plugin-user-settings": "^0.1.1-alpha.24",
"@backstage/test-utils": "^0.1.1-alpha.24",
"@backstage/theme": "^0.1.1-alpha.24",
"@material-ui/core": "^4.11.0",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 667b14366f..81d08f58a9 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -33,6 +33,7 @@ import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
+import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
import { Route, Routes, Navigate } from 'react-router';
import { EntityPage } from './components/catalog/EntityPage';
@@ -81,6 +82,7 @@ const AppRoutes = () => (
path="/register-component"
element={ }
/>
+ } />
{...deprecatedAppRoutes}
);
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 45ffca7801..e947e5c91d 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -35,11 +35,10 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
- SidebarUserSettings,
- DefaultProviderSettings,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
import { graphiQLRouteRef } from '@backstage/plugin-graphiql';
+import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -103,7 +102,7 @@ const Root: FC<{}> = ({ children }) => (
/>
- } />
+
{children}
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index c2483b40ee..393ffe2631 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -49,6 +49,11 @@ import {
import { Entity } from '@backstage/catalog-model';
import { Button, Grid } from '@material-ui/core';
import { EmptyState } from '@backstage/core';
+import {
+ EmbeddedRouter as LighthouseRouter,
+ LastLighthouseAuditCard,
+ isPluginApplicableToEntity as isLighthouseAvailable,
+} from '@backstage/plugin-lighthouse/';
const CICDSwitcher = ({ entity }: { entity: Entity }) => {
// This component is just an example of how you can implement your company's logic in entity page.
@@ -115,6 +120,11 @@ const OverviewContent = ({ entity }: { entity: Entity }) => (
+ {isLighthouseAvailable(entity) && (
+
+
+
+ )}
);
@@ -165,6 +175,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
title="CI/CD"
element={ }
/>
+ }
+ />
{
+ const createConfig = (data: any) =>
+ ConfigReader.fromConfigs([
+ {
+ context: '',
+ data,
+ },
+ ]);
+
+ const defaultConfigOptions = {
+ backend: {
+ database: {
+ client: 'pg',
+ connection: {
+ host: 'localhost',
+ user: 'foo',
+ password: 'bar',
+ database: 'foodb',
+ },
+ },
+ },
+ };
+ const defaultConfig = () => createConfig(defaultConfigOptions);
+
+ // This is similar to the ts-jest `mocked` helper.
+ const mocked = (f: Function) => f as jest.Mock;
+
+ afterEach(() => jest.resetAllMocks());
+
+ describe('SingleConnectionDatabaseManager.fromConfig', () => {
+ it('accesses the backend.database key', () => {
+ const getConfig = jest.fn();
+ const config = defaultConfig();
+ config.getConfig = getConfig;
+
+ SingleConnectionDatabaseManager.fromConfig(config);
+
+ expect(getConfig.mock.calls[0][0]).toEqual('backend.database');
+ });
+ });
+
+ describe('SingleConnectionDatabaseManager.forPlugin', () => {
+ const manager = SingleConnectionDatabaseManager.fromConfig(defaultConfig());
+
+ it('connects to a database scoped to the plugin', async () => {
+ const pluginId = 'test1';
+ await manager.forPlugin(pluginId).getClient();
+
+ expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(1);
+
+ const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
+ const callArgs = mockCalls[0];
+ expect(callArgs[0].get()).toEqual(defaultConfigOptions.backend.database);
+ expect(callArgs[1].connection.database).toEqual(
+ `backstage_plugin_${pluginId}`,
+ );
+ });
+
+ it('provides different plugins different databases', async () => {
+ const plugin1Id = 'test1';
+ const plugin2Id = 'test2';
+ await manager.forPlugin(plugin1Id).getClient();
+ await manager.forPlugin(plugin2Id).getClient();
+
+ expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(2);
+
+ const mockCalls = mocked(createDatabaseClient).mock.calls;
+ const plugin1CallArgs = mockCalls[0];
+ const plugin2CallArgs = mockCalls[1];
+ expect(plugin1CallArgs[1].connection.database).not.toEqual(
+ plugin2CallArgs[1].connection.database,
+ );
+ });
+ });
+});
diff --git a/packages/backend-common/src/database/SingleConnection.ts b/packages/backend-common/src/database/SingleConnection.ts
new file mode 100644
index 0000000000..777ced54a0
--- /dev/null
+++ b/packages/backend-common/src/database/SingleConnection.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 Knex from 'knex';
+import { Config } from '@backstage/config';
+import { createDatabaseClient, ensureDatabaseExists } from './connection';
+import { PluginDatabaseManager } from './types';
+
+/**
+ * Implements a Database Manager which will automatically create new databases
+ * for plugins when requested. All requested databases are created with the
+ * credentials provided; if the database already exists no attempt to create
+ * the database will be made.
+ */
+export class SingleConnectionDatabaseManager {
+ /**
+ * Creates a new SingleConnectionDatabaseManager instance by reading from the `backend`
+ * config section, specifically the `.database` key for discovering the management
+ * database configuration.
+ *
+ * @param config The loaded application configuration.
+ */
+ static fromConfig(config: Config): SingleConnectionDatabaseManager {
+ return new SingleConnectionDatabaseManager(
+ config.getConfig('backend.database'),
+ );
+ }
+
+ private constructor(private readonly config: Config) {}
+
+ /**
+ * Generates a PluginDatabaseManager for consumption by plugins.
+ *
+ * @param pluginId The plugin that the database manager should be created for. Plugin names should be unique.
+ */
+ forPlugin(pluginId: string): PluginDatabaseManager {
+ const _this = this;
+
+ return {
+ getClient(): Promise {
+ return _this.getDatabase(pluginId);
+ },
+ };
+ }
+
+ private async getDatabase(pluginId: string): Promise {
+ const config = this.config;
+ const overrides = SingleConnectionDatabaseManager.getDatabaseOverrides(
+ pluginId,
+ );
+ const overrideConfig = overrides.connection as Knex.ConnectionConfig;
+ await this.ensureDatabase(overrideConfig.database);
+
+ return createDatabaseClient(config, overrides);
+ }
+
+ private static getDatabaseOverrides(pluginId: string): Knex.Config {
+ return {
+ connection: {
+ database: `backstage_plugin_${pluginId}`,
+ },
+ };
+ }
+
+ private async ensureDatabase(database: string) {
+ const config = this.config;
+ await ensureDatabaseExists(config, database);
+ }
+}
diff --git a/packages/backend-common/src/database/config.test.ts b/packages/backend-common/src/database/config.test.ts
index ab29bc19ba..c9a4107686 100644
--- a/packages/backend-common/src/database/config.test.ts
+++ b/packages/backend-common/src/database/config.test.ts
@@ -18,6 +18,18 @@ import { mergeDatabaseConfig } from './config';
describe('config', () => {
describe(mergeDatabaseConfig, () => {
+ it('does not mutate the input object', () => {
+ const input = {
+ original: 'key',
+ };
+ const override = {
+ added: 'value',
+ };
+
+ mergeDatabaseConfig(input, override);
+ expect(input).not.toHaveProperty('added');
+ });
+
it('does not require overrides', () => {
expect(
mergeDatabaseConfig({
diff --git a/packages/backend-common/src/database/config.ts b/packages/backend-common/src/database/config.ts
index 80abc06aa2..af32556c28 100644
--- a/packages/backend-common/src/database/config.ts
+++ b/packages/backend-common/src/database/config.ts
@@ -19,9 +19,9 @@ import { merge } from 'lodash';
/**
* Merges database objects together
*
- * @param config The base config
+ * @param config The base config. The input is not modified
* @param overrides Any additional overrides
*/
export function mergeDatabaseConfig(config: any, ...overrides: any[]) {
- return merge(config, ...overrides);
+ return merge({}, config, ...overrides);
}
diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts
index 38d3d6224b..c81153aa62 100644
--- a/packages/backend-common/src/database/index.ts
+++ b/packages/backend-common/src/database/index.ts
@@ -15,3 +15,5 @@
*/
export * from './connection';
+export * from './types';
+export * from './SingleConnection';
diff --git a/packages/backend-common/src/database/types.ts b/packages/backend-common/src/database/types.ts
new file mode 100644
index 0000000000..7eaa05f173
--- /dev/null
+++ b/packages/backend-common/src/database/types.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 knex from 'knex';
+
+/**
+ * The PluginDatabaseManager manages access to databases that Plugins get.
+ */
+export interface PluginDatabaseManager {
+ /**
+ * getClient provides backend plugins database connections for itself.
+ *
+ * The purpose of this method is to allow plugins to get isolated data
+ * stores so that plugins are discouraged from database integration.
+ */
+ getClient(): Promise;
+}
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 959af71427..18fd44f089 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -24,17 +24,16 @@
import Router from 'express-promise-router';
import {
- ensureDatabaseExists,
- createDatabaseClient,
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
notFoundHandler,
+ SingleConnectionDatabaseManager,
SingleHostDiscovery,
UrlReaders,
} from '@backstage/backend-common';
-import { ConfigReader, AppConfig } from '@backstage/config';
+import { ConfigReader } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
@@ -48,24 +47,18 @@ import graphql from './plugins/graphql';
import app from './plugins/app';
import { PluginEnvironment } from './types';
-function makeCreateEnv(loadedConfigs: AppConfig[]) {
- const config = ConfigReader.fromConfigs(loadedConfigs);
+function makeCreateEnv(config: ConfigReader) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = SingleHostDiscovery.fromConfig(config);
root.info(`Created UrlReader ${reader}`);
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
+
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
- const database = createDatabaseClient(
- config.getConfig('backend.database'),
- {
- connection: {
- database: `backstage_plugin_${plugin}`,
- },
- },
- );
+ const database = databaseManager.forPlugin(plugin);
return { logger, database, config, reader, discovery };
};
}
@@ -73,12 +66,7 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
async function main() {
const configs = await loadBackendConfig();
const configReader = ConfigReader.fromConfigs(configs);
- const createEnv = makeCreateEnv(configs);
- await ensureDatabaseExists(
- configReader.getConfig('backend.database'),
- 'backstage_plugin_catalog',
- 'backstage_plugin_auth',
- );
+ const createEnv = makeCreateEnv(configReader);
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts
index c1d390f8cc..b8e99e6398 100644
--- a/packages/backend/src/plugins/catalog.ts
+++ b/packages/backend/src/plugins/catalog.ts
@@ -34,7 +34,9 @@ export default async function createPlugin({
}: PluginEnvironment) {
const locationReader = new LocationReaders({ logger, reader, config });
- const db = await DatabaseManager.createDatabase(database, { logger });
+ const db = await DatabaseManager.createDatabase(await database.getClient(), {
+ logger,
+ });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts
index c5b36ab8da..90667bfb16 100644
--- a/packages/backend/src/plugins/scaffolder.ts
+++ b/packages/backend/src/plugins/scaffolder.ts
@@ -47,13 +47,12 @@ export default async function createPlugin({
templaters.register('cra', craTemplater);
const filePreparer = new FilePreparer();
- const githubPreparer = new GithubPreparer();
+
const gitlabPreparer = new GitlabPreparer(config);
const azurePreparer = new AzurePreparer(config);
const preparers = new Preparers();
preparers.register('file', filePreparer);
- preparers.register('github', githubPreparer);
preparers.register('gitlab', gitlabPreparer);
preparers.register('gitlab/api', gitlabPreparer);
preparers.register('azure/api', azurePreparer);
@@ -75,6 +74,10 @@ export default async function createPlugin({
token: githubToken,
repoVisibility,
});
+
+ const githubPreparer = new GithubPreparer({ token: githubToken });
+
+ preparers.register('github', githubPreparer);
publishers.register('file', githubPublisher);
publishers.register('github', githubPublisher);
} catch (e) {
diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts
index e04d2a2732..6e36c26f39 100644
--- a/packages/backend/src/plugins/techdocs.ts
+++ b/packages/backend/src/plugins/techdocs.ts
@@ -23,6 +23,7 @@ import {
TechdocsGenerator,
GithubPreparer,
GitlabPreparer,
+ AzurePreparer,
} from '@backstage/plugin-techdocs-backend';
import { PluginEnvironment } from '../types';
import Docker from 'dockerode';
@@ -39,10 +40,12 @@ export default async function createPlugin({
const preparers = new Preparers();
const githubPreparer = new GithubPreparer(logger);
const gitlabPreparer = new GitlabPreparer(logger);
+ const azurePreparer = new AzurePreparer(logger);
const directoryPreparer = new DirectoryPreparer(logger);
preparers.register('dir', directoryPreparer);
preparers.register('github', githubPreparer);
preparers.register('gitlab', gitlabPreparer);
+ preparers.register('azure/api', azurePreparer);
const publisher = new LocalPublish(logger);
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 9257fcc9cf..f63b9dd780 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -14,14 +14,17 @@
* limitations under the License.
*/
-import Knex from 'knex';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
-import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common';
+import {
+ PluginDatabaseManager,
+ PluginEndpointDiscovery,
+ UrlReader,
+} from '@backstage/backend-common';
export type PluginEnvironment = {
logger: Logger;
- database: Knex;
+ database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
diff --git a/packages/catalog-model/examples/spotify-api.yaml b/packages/catalog-model/examples/spotify-api.yaml
index 30524dfdd4..14deb17abe 100644
--- a/packages/catalog-model/examples/spotify-api.yaml
+++ b/packages/catalog-model/examples/spotify-api.yaml
@@ -7,8 +7,11 @@ metadata:
- spotify
- rest
annotations:
+ # The annotation is deprecated, we use placeholders (see below) instead, remove it later.
backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml'
spec:
type: openapi
lifecycle: production
owner: spotify@example.com
+ definition:
+ $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/spotify.com/v1/swagger.yaml
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index f69e18190d..b9f709241c 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -96,7 +96,7 @@ async function getConfig() {
// Default behaviour is to not apply transforms for node_modules, but we still want
// to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: [
- `/node_modules/${transformModulePattern}(?:(?!\\.esm).)*\\.(?:js|json)$`,
+ `/node_modules/${transformModulePattern}.*\\.(?:(?;
};
/**
diff --git a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
index b6aac75620..dd9657d7d8 100644
--- a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
+++ b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
@@ -15,11 +15,8 @@
*/
import { createApiRef } from '../ApiRef';
-import {
- UserFlags,
- FeatureFlagsRegistry,
- FeatureFlagsRegistryItem,
-} from '../../app/FeatureFlags';
+import { UserFlags, FeatureFlagsRegistry } from '../../app/FeatureFlags';
+import { FeatureFlagName } from '../../plugin';
/**
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
@@ -55,6 +52,11 @@ export interface FeatureFlagsApi {
getRegisteredFlags(): FeatureFlagsRegistry;
}
+export interface FeatureFlagsRegistryItem {
+ pluginId: string;
+ name: FeatureFlagName;
+}
+
export const featureFlagsApiRef = createApiRef({
id: 'core.featureflags',
description: 'Used to toggle functionality in features across Backstage',
diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx
index 9dd2fb138f..c222c3c420 100644
--- a/packages/core-api/src/app/App.tsx
+++ b/packages/core-api/src/app/App.tsx
@@ -30,12 +30,12 @@ import {
SignInPageProps,
} from './types';
import { BackstagePlugin } from '../plugin';
-import { FeatureFlagsRegistryItem } from './FeatureFlags';
import {
featureFlagsApiRef,
AppThemeApi,
ConfigApi,
identityApiRef,
+ FeatureFlagsRegistryItem,
} from '../apis/definitions';
import { AppThemeProvider } from './AppThemeProvider';
diff --git a/packages/core-api/src/app/FeatureFlags.tsx b/packages/core-api/src/app/FeatureFlags.tsx
index 3db2a18a02..7a69fa5456 100644
--- a/packages/core-api/src/app/FeatureFlags.tsx
+++ b/packages/core-api/src/app/FeatureFlags.tsx
@@ -15,7 +15,11 @@
*/
import { FeatureFlagName } from '../plugin/types';
-import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions';
+import {
+ FeatureFlagState,
+ FeatureFlagsApi,
+ FeatureFlagsRegistryItem,
+} from '../apis/definitions';
/**
* Helper method for validating compatibility and flag name.
@@ -129,10 +133,6 @@ export class UserFlags extends Map {
* This acts as a holding data structure for feature flags
* that plugins wish to register for use in Backstage.
*/
-export interface FeatureFlagsRegistryItem {
- pluginId: string;
- name: FeatureFlagName;
-}
export class FeatureFlagsRegistry extends Array {
static from(entries: FeatureFlagsRegistryItem[]) {
diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts
index 882ecd3d8b..8a1eb92e8e 100644
--- a/packages/core-api/src/app/types.ts
+++ b/packages/core-api/src/app/types.ts
@@ -112,11 +112,13 @@ export type AppOptions = {
* title: 'Light Theme',
* variant: 'light',
* theme: lightTheme,
+ * icon: ,
* }, {
* id: 'dark',
* title: 'Dark Theme',
* variant: 'dark',
* theme: darkTheme,
+ * icon: ,
* }]
* ```
*/
diff --git a/packages/core/package.json b/packages/core/package.json
index f125c9d67e..b665006fd2 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -39,8 +39,9 @@
"@types/react-sparklines": "^1.7.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
+ "immer": "^7.0.9",
"lodash": "^4.17.15",
- "material-table": "1.68.0",
+ "material-table": "^1.69.1",
"prop-types": "^15.7.2",
"rc-progress": "^3.0.0",
"react": "^16.12.0",
diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx
index 7fe77423e2..c7d0f5ce01 100644
--- a/packages/core/src/api-wrappers/createApp.tsx
+++ b/packages/core/src/api-wrappers/createApp.tsx
@@ -22,7 +22,8 @@ import privateExports, {
AppConfigLoader,
} from '@backstage/core-api';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
-
+import LightIcon from '@material-ui/icons/WbSunny';
+import DarkIcon from '@material-ui/icons/Brightness2';
import { ErrorPage } from '../layout/ErrorPage';
import { Progress } from '../components/Progress';
import { defaultApis } from './defaultApis';
@@ -110,12 +111,14 @@ export function createApp(options?: AppOptions) {
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
+ icon: ,
},
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: darkTheme,
+ icon: ,
},
];
const configLoader = options?.configLoader ?? defaultConfigLoader;
diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx
index aaca389f82..a5005574fe 100644
--- a/packages/core/src/components/Button/Button.stories.tsx
+++ b/packages/core/src/components/Button/Button.stories.tsx
@@ -24,7 +24,7 @@ const Location = () => {
};
export default {
- title: 'Button',
+ title: 'Inputs/Button',
component: Button,
decorators: [
(storyFn: FunctionComponentFactory<{}>) => (
diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx
new file mode 100644
index 0000000000..12f19b17a1
--- /dev/null
+++ b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { CheckboxTree } from '.';
+
+const CHECKBOX_TREE_ITEMS = [
+ {
+ label: 'Genereic subcategory name 1',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+ {
+ label: 'Genereic subcategory name 2',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+ {
+ label: 'Genereic subcategory name 3',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+];
+
+export default {
+ title: 'CheckboxTree',
+ component: CheckboxTree,
+};
+
+export const Default = () => (
+ {}}
+ label="default"
+ subCategories={CHECKBOX_TREE_ITEMS}
+ />
+);
diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx
new file mode 100644
index 0000000000..f5eaa0e58b
--- /dev/null
+++ b/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+
+import { CheckboxTree } from '.';
+
+const CHECKBOX_TREE_ITEMS = [
+ {
+ label: 'Genereic subcategory name 1',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+];
+
+const minProps = {
+ onChange: jest.fn(),
+ label: 'Default',
+ subCategories: CHECKBOX_TREE_ITEMS,
+};
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText, getByTestId } = render( );
+
+ expect(getByText('Genereic subcategory name 1')).toBeInTheDocument();
+ const checkbox = await getByTestId('expandable');
+
+ // Simulate click on expandable arrow
+ fireEvent.click(checkbox);
+
+ // Simulate click on option
+ const option = getByText('Option 1');
+ expect(getByText('Option 1')).toBeInTheDocument();
+ fireEvent.click(option);
+ expect(minProps.onChange).toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx
new file mode 100644
index 0000000000..6825704971
--- /dev/null
+++ b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx
@@ -0,0 +1,294 @@
+/*
+ * 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.
+ */
+/* eslint-disable guard-for-in */
+import React, { useEffect, useReducer } from 'react';
+import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
+import {
+ List,
+ ListItem,
+ ListItemIcon,
+ Checkbox,
+ ListItemText,
+ Collapse,
+ Typography,
+} from '@material-ui/core';
+import ExpandLess from '@material-ui/icons/ExpandLess';
+import ExpandMore from '@material-ui/icons/ExpandMore';
+import produce from 'immer';
+
+type IndexedObject = {
+ [key: string]: T;
+};
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ root: {
+ width: '100%',
+ minWidth: 10,
+ maxWidth: 360,
+ backgroundColor: 'transparent',
+ '&:hover': {
+ backgroundColor: 'transparent',
+ },
+ '&:active': {
+ animation: 'none',
+ transform: 'none',
+ },
+ },
+ nested: {
+ paddingLeft: theme.spacing(5),
+ height: '32px',
+ '&:hover': {
+ backgroundColor: 'transparent',
+ },
+ },
+ listItemIcon: {
+ minWidth: 10,
+ },
+ listItem: {
+ '&:hover': {
+ backgroundColor: 'transparent',
+ },
+ },
+ text: {
+ '& span, & svg': {
+ fontWeight: 'normal',
+ fontSize: 14,
+ },
+ },
+ }),
+);
+
+/* SUB_CATEGORY */
+
+type SubCategory = {
+ label: string;
+ isChecked?: boolean;
+ isOpen?: boolean;
+ options?: Option[];
+};
+
+type SubCategoryWithIndexedOptions = {
+ label: string;
+ isChecked?: boolean;
+ isOpen?: boolean;
+ options: IndexedObject;
+};
+
+/* OPTION */
+
+type Option = {
+ label: string;
+ value: string | number;
+ isChecked?: boolean;
+};
+
+export type CheckboxTreeProps = {
+ subCategories: SubCategory[];
+ label: string;
+ triggerReset?: boolean;
+ onChange: (arg: any) => any;
+};
+
+/* REDUCER */
+
+type checkOptionPayload = {
+ subCategoryLabel: string;
+ optionLabel: string;
+};
+
+type Action =
+ | { type: 'checkOption'; payload: checkOptionPayload }
+ | { type: 'checkCategory'; payload: string }
+ | { type: 'toggleCategory'; payload: string }
+ | { type: 'triggerReset' };
+
+const reducer = (
+ state: IndexedObject,
+ action: Action,
+) => {
+ switch (action.type) {
+ case 'checkOption': {
+ return produce(state, newState => {
+ const category = newState[action.payload.subCategoryLabel];
+ const option = category.options[action.payload.optionLabel];
+ option.isChecked = !option.isChecked;
+ category.isChecked = Object.values(category.options).every(
+ o => o.isChecked,
+ );
+ });
+ }
+ case 'checkCategory': {
+ return produce(state, newState => {
+ const category = newState[action.payload];
+ const options = category.options;
+ category.isChecked = !category.isChecked;
+ for (const option in options) {
+ options[option].isChecked = category.isChecked;
+ }
+ });
+ }
+ case 'toggleCategory':
+ return produce(state, newState => {
+ const category = newState[action.payload];
+ category.isOpen = !category.isOpen;
+ });
+ case 'triggerReset': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ newState[category].isChecked = false;
+ for (const option in newState[category].options) {
+ newState[category].options[option].isChecked =
+ newState[category].isChecked;
+ }
+ }
+ });
+ }
+ default:
+ return state;
+ }
+};
+
+const indexer = (
+ arr: SubCategory[],
+): IndexedObject =>
+ arr.reduce((accumulator, el) => {
+ if (el.options) {
+ return {
+ ...accumulator,
+ [el.label]: {
+ label: el.label,
+ isChecked: el.isChecked || false,
+ isOpen: false,
+ options: indexer(el.options),
+ },
+ };
+ }
+ return {
+ ...accumulator,
+ [el.label]: { ...el, isChecked: el.isChecked || false },
+ };
+ }, {});
+
+export const CheckboxTree = (props: CheckboxTreeProps) => {
+ const { onChange } = props;
+ const classes = useStyles();
+
+ const [state, dispatch] = useReducer(reducer, indexer(props.subCategories));
+
+ const handleOpen = (event: any, value: any) => {
+ event.stopPropagation();
+ dispatch({ type: 'toggleCategory', payload: value });
+ };
+
+ useEffect(() => {
+ const values = Object.values(state).map(category => ({
+ category: category.isChecked ? category.label : null,
+ selectedChilds: Object.values(category.options)
+ .filter(option => option.isChecked)
+ .map(option => option.value),
+ }));
+ onChange(values);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state]);
+
+ useEffect(() => {
+ dispatch({ type: 'triggerReset' });
+ }, [props.triggerReset]);
+
+ return (
+
+
{props.label}
+
+ {Object.values(state).map(item => (
+
+
+ dispatch({
+ type: 'checkCategory',
+ payload: item.label,
+ })
+ }
+ >
+
+
+
+
+ {Object.values(item.options).length ? (
+ <>
+ {item.isOpen ? (
+ handleOpen(event, item.label)}
+ />
+ ) : (
+ handleOpen(event, item.label)}
+ />
+ )}
+ >
+ ) : null}
+
+
+
+ {Object.values(item.options).map(option => (
+
+ dispatch({
+ type: 'checkOption',
+ payload: {
+ subCategoryLabel: item.label,
+ optionLabel: option.label,
+ },
+ })
+ }
+ >
+
+
+
+
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/core/src/layout/Sidebar/Settings/AppSettingsList.tsx b/packages/core/src/components/CheckboxTree/index.tsx
similarity index 62%
rename from packages/core/src/layout/Sidebar/Settings/AppSettingsList.tsx
rename to packages/core/src/components/CheckboxTree/index.tsx
index 5dad178ccd..d8e62460ab 100644
--- a/packages/core/src/layout/Sidebar/Settings/AppSettingsList.tsx
+++ b/packages/core/src/components/CheckboxTree/index.tsx
@@ -13,14 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import React from 'react';
-import { List, ListSubheader } from '@material-ui/core';
-import { SidebarThemeToggle } from './ThemeToggle';
-import { SidebarPinButton } from './PinButton';
-export const AppSettingsList = () => (
- App Settings}>
-
-
-
-);
+export { CheckboxTree } from './CheckboxTree';
diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx
index 77f3ca51cc..6df7e5bb6d 100644
--- a/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx
+++ b/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx
@@ -19,7 +19,7 @@ import { CodeSnippet } from './CodeSnippet';
import { InfoCard } from '../../layout/InfoCard';
export default {
- title: 'CodeSnippet',
+ title: 'Data Display/CodeSnippet',
component: CodeSnippet,
};
diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx
index 1894574df2..e6f5f8cece 100644
--- a/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx
+++ b/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx
@@ -18,7 +18,7 @@ import React from 'react';
import { CopyTextButton } from '.';
export default {
- title: 'CopyTextButton',
+ title: 'Inputs/CopyTextButton',
component: CopyTextButton,
};
diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx
index 148e7b6803..d77a713b34 100644
--- a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx
+++ b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx
@@ -28,7 +28,7 @@ import {
} from '@backstage/core-api';
export default {
- title: 'DismissableBanner',
+ title: 'Feedback/DismissableBanner',
component: DismissableBanner,
};
diff --git a/packages/core/src/components/EmptyState/EmptyState.stories.tsx b/packages/core/src/components/EmptyState/EmptyState.stories.tsx
index 15d61f14c0..dcfba73227 100644
--- a/packages/core/src/components/EmptyState/EmptyState.stories.tsx
+++ b/packages/core/src/components/EmptyState/EmptyState.stories.tsx
@@ -20,19 +20,15 @@ import { Button } from '@material-ui/core';
import { MissingAnnotationEmptyState } from './MissingAnnotationEmptyState';
export default {
- title: 'EmptyState',
+ title: 'Feedback/EmptyState',
component: EmptyState,
};
const containerStyle = { width: '100%', height: '100vh' };
-export const Field = () => (
+export const MissingAnnotation = () => (
-
+
);
@@ -80,9 +76,3 @@ export const WithAction = () => (
/>
);
-
-export const MissingAnnotation = () => (
-
-
-
-);
diff --git a/packages/core/src/components/EmptyState/EmptyState.tsx b/packages/core/src/components/EmptyState/EmptyState.tsx
index 4469c3ce86..1af5ff6dd5 100644
--- a/packages/core/src/components/EmptyState/EmptyState.tsx
+++ b/packages/core/src/components/EmptyState/EmptyState.tsx
@@ -17,7 +17,6 @@
import React from 'react';
import { makeStyles, Typography, Grid } from '@material-ui/core';
import { EmptyStateImage } from './EmptyStateImage';
-import Background from './assets/Background.svg';
const useStyles = makeStyles(theme => ({
root: {
@@ -30,10 +29,6 @@ const useStyles = makeStyles(theme => ({
imageContainer: {
position: 'relative',
},
- backgroundImage: {
- position: 'absolute',
- width: '100%',
- },
}));
type Props = {
@@ -66,11 +61,6 @@ export const EmptyState = ({ title, description, missing, action }: Props) => {
-
diff --git a/packages/core/src/components/EmptyState/EmptyStateImage.tsx b/packages/core/src/components/EmptyState/EmptyStateImage.tsx
index 49598ae6db..d52a0f7dcf 100644
--- a/packages/core/src/components/EmptyState/EmptyStateImage.tsx
+++ b/packages/core/src/components/EmptyState/EmptyStateImage.tsx
@@ -27,7 +27,7 @@ type Props = {
const useStyles = makeStyles({
generalImg: {
- width: '75%',
+ width: '95%',
zIndex: 2,
position: 'absolute',
left: '50%',
diff --git a/packages/core/src/components/EmptyState/assets/Background.svg b/packages/core/src/components/EmptyState/assets/Background.svg
deleted file mode 100644
index ce9aae6739..0000000000
--- a/packages/core/src/components/EmptyState/assets/Background.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/core/src/components/EmptyState/assets/createComponent.svg b/packages/core/src/components/EmptyState/assets/createComponent.svg
index bb8b50e1ef..2634bb7f23 100644
--- a/packages/core/src/components/EmptyState/assets/createComponent.svg
+++ b/packages/core/src/components/EmptyState/assets/createComponent.svg
@@ -1 +1,36 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg
index 39534656fa..4f69605e6a 100644
--- a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg
+++ b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg
@@ -1 +1,42 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/src/components/EmptyState/assets/noBuild.svg b/packages/core/src/components/EmptyState/assets/noBuild.svg
index ef45e1c736..d84f36af30 100644
--- a/packages/core/src/components/EmptyState/assets/noBuild.svg
+++ b/packages/core/src/components/EmptyState/assets/noBuild.svg
@@ -1 +1,44 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/src/components/EmptyState/assets/noInformation.svg b/packages/core/src/components/EmptyState/assets/noInformation.svg
index 9a1c230e97..c199cf8b13 100644
--- a/packages/core/src/components/EmptyState/assets/noInformation.svg
+++ b/packages/core/src/components/EmptyState/assets/noInformation.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx
index b65c5bff5d..9718393427 100644
--- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx
+++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx
@@ -21,7 +21,7 @@ const containerStyle = { width: 800, height: 400, margin: 20 };
const opacityArray = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
export default {
- title: 'HorizontalScrollGrid',
+ title: 'Layout/HorizontalScrollGrid',
component: HorizontalScrollGrid,
};
diff --git a/packages/core/src/components/Lifecycle/Lifecycle.stories.tsx b/packages/core/src/components/Lifecycle/Lifecycle.stories.tsx
index c29eb8a02f..ccce7fba25 100644
--- a/packages/core/src/components/Lifecycle/Lifecycle.stories.tsx
+++ b/packages/core/src/components/Lifecycle/Lifecycle.stories.tsx
@@ -17,7 +17,7 @@ import React from 'react';
import { Lifecycle } from './Lifecycle';
export default {
- title: 'Lifecycle',
+ title: 'Feedback/Lifecycle',
component: Lifecycle,
};
diff --git a/packages/core/src/components/Link/Link.stories.tsx b/packages/core/src/components/Link/Link.stories.tsx
index 2bd499fa33..852d51970f 100644
--- a/packages/core/src/components/Link/Link.stories.tsx
+++ b/packages/core/src/components/Link/Link.stories.tsx
@@ -29,7 +29,7 @@ const Location = () => {
};
export default {
- title: 'Link',
+ title: 'Navigation/Link',
component: Link,
decorators: [
(storyFn: FunctionComponentFactory<{}>) => (
diff --git a/packages/core/src/components/Progress/Progress.stories.tsx b/packages/core/src/components/Progress/Progress.stories.tsx
index b935f60695..cd17dce8b4 100644
--- a/packages/core/src/components/Progress/Progress.stories.tsx
+++ b/packages/core/src/components/Progress/Progress.stories.tsx
@@ -18,7 +18,7 @@ import React from 'react';
import { Progress } from '.';
export default {
- title: 'Progress',
+ title: 'Feedback/Progress',
component: Progress,
};
diff --git a/packages/core/src/components/ProgressBars/Gauge.stories.tsx b/packages/core/src/components/ProgressBars/Gauge.stories.tsx
index 6599b0f768..7882714529 100644
--- a/packages/core/src/components/ProgressBars/Gauge.stories.tsx
+++ b/packages/core/src/components/ProgressBars/Gauge.stories.tsx
@@ -20,7 +20,7 @@ import { Gauge } from './Gauge';
const containerStyle = { width: 300 };
export default {
- title: 'Gauge',
+ title: 'Feedback/Gauge',
component: Gauge,
};
diff --git a/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx b/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx
index 922bb63b2f..45de49cd2d 100644
--- a/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx
+++ b/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx
@@ -21,7 +21,7 @@ import { Grid } from '@material-ui/core';
const linkInfo = { title: 'Go to XYZ Location', link: '#' };
export default {
- title: 'Progress Card',
+ title: 'Data Display/Progress Card',
component: GaugeCard,
};
diff --git a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx
index c4492986b6..a745d210b4 100644
--- a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx
+++ b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx
@@ -20,7 +20,7 @@ import { LinearGauge } from './LinearGauge';
const containerStyle = { width: 300 };
export default {
- title: 'LinearGauge',
+ title: 'Feedback/LinearGauge',
component: LinearGauge,
};
diff --git a/packages/core/src/components/Select/Select.stories.tsx b/packages/core/src/components/Select/Select.stories.tsx
new file mode 100644
index 0000000000..feb5c1cffb
--- /dev/null
+++ b/packages/core/src/components/Select/Select.stories.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Select } from '.';
+
+export default {
+ title: 'Select',
+ component: Select,
+};
+
+const SELECT_ITEMS = [
+ {
+ label: 'test 1',
+ value: 'test_1',
+ },
+ {
+ label: 'test 2',
+ value: 'test_2',
+ },
+ {
+ label: 'test 3',
+ value: 'test_3',
+ },
+];
+
+export const Default = () => (
+ {}}
+ placeholder="All results"
+ label="Default"
+ items={SELECT_ITEMS}
+ />
+);
+
+export const Multiple = () => (
+ {}}
+ />
+);
diff --git a/packages/core/src/components/Select/Select.test.tsx b/packages/core/src/components/Select/Select.test.tsx
new file mode 100644
index 0000000000..9063804573
--- /dev/null
+++ b/packages/core/src/components/Select/Select.test.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+
+import { Select } from '.';
+
+const SELECT_ITEMS = [
+ {
+ label: 'test 1',
+ value: 'test_1',
+ },
+ {
+ label: 'test 2',
+ value: 'test_2',
+ },
+];
+
+const minProps = {
+ onChange: jest.fn(),
+ label: 'Default',
+ placeholder: 'All results',
+ items: SELECT_ITEMS,
+};
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText, getByTestId } = render( );
+
+ expect(getByText('Default')).toBeInTheDocument();
+ const input = await getByTestId('select');
+ expect(input.textContent).toBe('All results');
+
+ // Simulate click on input
+ fireEvent.click(input);
+
+ expect(getByText('test 1')).toBeInTheDocument();
+ const option = getByText('test 1');
+
+ // Simulate click on option
+ fireEvent.click(option);
+ expect(input.textContent).toBe('test 1');
+ });
+});
diff --git a/packages/core/src/components/Select/Select.tsx b/packages/core/src/components/Select/Select.tsx
new file mode 100644
index 0000000000..a2efcc7725
--- /dev/null
+++ b/packages/core/src/components/Select/Select.tsx
@@ -0,0 +1,219 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import {
+ createStyles,
+ makeStyles,
+ withStyles,
+ Theme,
+} from '@material-ui/core/styles';
+
+import {
+ FormControl,
+ Select,
+ MenuItem,
+ InputBase,
+ Chip,
+ Typography,
+ Checkbox,
+ ClickAwayListener,
+} from '@material-ui/core';
+
+import ClosedDropdown from './static/ClosedDropdown';
+import OpenedDropdown from './static/OpenedDropdown';
+
+const BootstrapInput = withStyles((theme: Theme) =>
+ createStyles({
+ root: {
+ 'label + &': {
+ marginTop: theme.spacing(3),
+ },
+ },
+ input: {
+ borderRadius: 4,
+ position: 'relative',
+ backgroundColor: theme.palette.background.paper,
+ border: '1px solid #ced4da',
+ fontSize: 16,
+ padding: '10px 26px 10px 12px',
+ transition: theme.transitions.create(['border-color', 'box-shadow']),
+ fontFamily: 'Helvetica Neue',
+ '&:focus': {
+ background: theme.palette.background.paper,
+ borderRadius: 4,
+ },
+ },
+ }),
+)(InputBase);
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ formControl: {
+ margin: `${theme.spacing(1)} 0px`,
+ maxWidth: 300,
+ },
+ label: {
+ transform: 'initial',
+ fontWeight: 'bold',
+ fontSize: 14,
+ fontFamily: theme.typography.fontFamily,
+ color: theme.palette.text.primary,
+ '&.Mui-focused': {
+ color: theme.palette.text.primary,
+ },
+ },
+ chips: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ },
+ chip: {
+ margin: 2,
+ },
+ checkbox: {},
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ }),
+);
+
+type Item = {
+ label: string;
+ value: string | number;
+};
+
+export type SelectProps = {
+ multiple?: boolean;
+ items: Item[];
+ label: string;
+ placeholder?: string;
+ onChange: (arg: any) => any;
+ triggerReset?: boolean;
+};
+
+export const SelectComponent = (props: SelectProps) => {
+ const { multiple, items, label, placeholder, onChange } = props;
+ const classes = useStyles();
+ const [value, setValue] = useState(
+ multiple ? [] : '',
+ );
+ const [canOpen, setCanOpen] = React.useState(false);
+
+ useEffect(() => {
+ setValue(multiple ? [] : '');
+ }, [props.triggerReset, multiple]);
+
+ const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
+ setValue(event.target.value as any);
+ onChange(event.target.value);
+ };
+
+ const selectHandleOnOpen = () => {
+ setCanOpen(previous => {
+ if (multiple) {
+ return true;
+ }
+ return !previous;
+ });
+ };
+
+ const handleClickAway = (event: React.ChangeEvent) => {
+ if (event.target.id !== 'menu-item') {
+ setCanOpen(false);
+ }
+ };
+
+ const handleDelete = (selectedValue: string | number) => () => {
+ const newValue = (value as any[]).filter(chip => chip !== selectedValue);
+ setValue(newValue);
+ onChange(newValue);
+ };
+
+ return (
+
+
{label}
+
+
+ }
+ renderValue={selected =>
+ multiple && (value as any[]).length !== 0 ? (
+
+ {(selected as string[]).map(selectedValue => (
+ el.value === selectedValue)?.value}
+ label={
+ items.find(el => el.value === selectedValue)?.label
+ }
+ clickable
+ onDelete={handleDelete(selectedValue)}
+ className={classes.chip}
+ />
+ ))}
+
+ ) : (
+
+ {(value as any[]).length === 0
+ ? placeholder || ''
+ : items.find(el => el.value === selected)?.label}
+
+ )
+ }
+ IconComponent={() =>
+ !canOpen ? :
+ }
+ MenuProps={{
+ anchorOrigin: {
+ vertical: 'bottom',
+ horizontal: 'left',
+ },
+ transformOrigin: {
+ vertical: 'top',
+ horizontal: 'left',
+ },
+ getContentAnchorEl: null,
+ }}
+ >
+ {placeholder && !multiple && (
+ {placeholder}
+ )}
+ {items &&
+ items.map(item => (
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/core/src/components/Select/index.tsx b/packages/core/src/components/Select/index.tsx
new file mode 100644
index 0000000000..977ebc88eb
--- /dev/null
+++ b/packages/core/src/components/Select/index.tsx
@@ -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 { SelectComponent as Select } from './Select';
diff --git a/packages/core/src/components/Select/static/ClosedDropdown.tsx b/packages/core/src/components/Select/static/ClosedDropdown.tsx
new file mode 100644
index 0000000000..235a91963b
--- /dev/null
+++ b/packages/core/src/components/Select/static/ClosedDropdown.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { SvgIcon, makeStyles, createStyles } from '@material-ui/core';
+
+const useStyles = makeStyles(() =>
+ createStyles({
+ icon: {
+ position: 'absolute',
+ right: '4px',
+ pointerEvents: 'none',
+ },
+ }),
+);
+
+const ClosedDropdown = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+export default ClosedDropdown;
diff --git a/packages/core/src/components/Select/static/OpenedDropdown.tsx b/packages/core/src/components/Select/static/OpenedDropdown.tsx
new file mode 100644
index 0000000000..e4a8021017
--- /dev/null
+++ b/packages/core/src/components/Select/static/OpenedDropdown.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { SvgIcon, makeStyles, createStyles } from '@material-ui/core';
+
+const useStyles = makeStyles(() =>
+ createStyles({
+ icon: {
+ position: 'absolute',
+ right: '4px',
+ pointerEvents: 'none',
+ },
+ }),
+);
+
+const OpenedDropdown = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+export default OpenedDropdown;
diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx
index 52710ffeaf..e7163ea533 100644
--- a/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx
+++ b/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx
@@ -19,7 +19,7 @@ import { TextField } from '@material-ui/core';
import { SimpleStepper, SimpleStepperStep } from '.';
export default {
- title: 'SimpleStepper',
+ title: 'Navigation/SimpleStepper',
component: SimpleStepper,
};
diff --git a/packages/core/src/components/Status/Status.stories.tsx b/packages/core/src/components/Status/Status.stories.tsx
index 01e2876524..560d07ad87 100644
--- a/packages/core/src/components/Status/Status.stories.tsx
+++ b/packages/core/src/components/Status/Status.stories.tsx
@@ -27,7 +27,7 @@ import { Table } from '../Table';
import { InfoCard } from '../../layout/InfoCard';
export default {
- title: 'Status',
+ title: 'Feedback/Status',
component: StatusOK,
};
diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx
index 9db7385872..97591ebab3 100644
--- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx
+++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx
@@ -37,7 +37,7 @@ const metadata = {
};
export default {
- title: 'Structured Metadata Table',
+ title: 'Data Display/Structured Metadata Table',
component: StructuredMetadataTable,
};
diff --git a/packages/core/src/components/Table/Filters.tsx b/packages/core/src/components/Table/Filters.tsx
new file mode 100644
index 0000000000..c8f468559a
--- /dev/null
+++ b/packages/core/src/components/Table/Filters.tsx
@@ -0,0 +1,147 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import { BackstageTheme } from '@backstage/theme';
+import { Button, makeStyles } from '@material-ui/core';
+import { Select } from '../Select';
+import { CheckboxTree } from '../CheckboxTree';
+import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
+import { SelectProps } from '../Select/Select';
+
+const useSubvalueCellStyles = makeStyles(theme => ({
+ root: {
+ height: '100%',
+ width: '315px',
+ display: 'flex',
+ flexDirection: 'column',
+ marginRight: theme.spacing(3),
+ },
+ value: {
+ fontWeight: 'bold',
+ fontSize: 18,
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ height: '60px',
+ justifyContent: 'space-between',
+ borderBottom: `1px solid ${theme.palette.grey[500]}`,
+ },
+ filters: {
+ display: 'flex',
+ flexDirection: 'column',
+ '& > *': {
+ marginTop: theme.spacing(2),
+ },
+ },
+}));
+
+type Without = Pick>;
+
+export type Filter = {
+ type: 'select' | 'checkbox-tree' | 'multiple-select';
+ element:
+ | Without
+ | Without;
+};
+
+export type SelectedFilters = {
+ [key: string]: string | string[];
+};
+
+type Props = {
+ filters: Filter[];
+ onChangeFilters: (arg: any) => any;
+};
+
+export const Filters = (props: Props) => {
+ const classes = useSubvalueCellStyles();
+
+ const { onChangeFilters } = props;
+
+ const [filters, setFilters] = useState(props.filters);
+ const [selectedFilters, setSelectedFilters] = useState({});
+ const [reset, triggerReset] = useState(false);
+
+ // Trigger re-rendering
+ const handleClick = () => {
+ setSelectedFilters({});
+ setFilters([...props.filters]);
+ triggerReset(el => !el);
+ };
+
+ useEffect(() => {
+ onChangeFilters(selectedFilters);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedFilters]);
+
+ // As material table doesn't provide a way to add a column filter tab we will make our own filter logic
+ return (
+
+
+
Filters
+
+ Clear all
+
+
+
+ {filters?.length &&
+ filters.map(filter =>
+ filter.type === 'checkbox-tree' ? (
+
+ setSelectedFilters({
+ ...selectedFilters,
+ [filter.element.label]: el
+ .filter(
+ (checkboxFilter: any) =>
+ checkboxFilter.category !== null ||
+ checkboxFilter.selectedChilds.length,
+ )
+ .map((checkboxFilter: any) =>
+ checkboxFilter.category !== null
+ ? [
+ ...checkboxFilter.selectedChilds,
+ checkboxFilter.category,
+ ]
+ : checkboxFilter.selectedChilds,
+ )
+ .flat(),
+ })
+ }
+ />
+ ) : (
+
+ setSelectedFilters({
+ ...selectedFilters,
+ [filter.element.label]: el,
+ })
+ }
+ />
+ ),
+ )}
+
+
+ );
+};
diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx
index 11fa0c44dc..4be85e494f 100644
--- a/packages/core/src/components/Table/Table.stories.tsx
+++ b/packages/core/src/components/Table/Table.stories.tsx
@@ -16,9 +16,10 @@
import React from 'react';
import { Table, SubvalueCell, TableColumn } from './';
+import { TableFilter } from './Table';
export default {
- title: 'Table',
+ title: 'Data Display/Table',
component: Table,
};
@@ -220,3 +221,53 @@ export const DenseTable = () => {
);
};
+
+export const FilterTable = () => {
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ const filters: TableFilter[] = [
+ {
+ column: 'Column 1',
+ type: 'select',
+ },
+ {
+ column: 'Column 2',
+ type: 'multiple-select',
+ },
+ {
+ column: 'Numeric value',
+ type: 'checkbox-tree',
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx
index 882272cb43..5d0b7d8344 100644
--- a/packages/core/src/components/Table/Table.tsx
+++ b/packages/core/src/components/Table/Table.tsx
@@ -15,7 +15,12 @@
*/
import { BackstageTheme } from '@backstage/theme';
-import { makeStyles, Typography, useTheme } from '@material-ui/core';
+import {
+ makeStyles,
+ Typography,
+ useTheme,
+ IconButton,
+} from '@material-ui/core';
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
import AddBox from '@material-ui/icons/AddBox';
import ArrowUpward from '@material-ui/icons/ArrowUpward';
@@ -39,7 +44,8 @@ import MTable, {
MTableToolbar,
Options,
} from 'material-table';
-import React, { forwardRef } from 'react';
+import React, { forwardRef, useCallback, useEffect, useState } from 'react';
+import { Filters, SelectedFilters } from './Filters';
const tableIcons = {
Add: forwardRef((props, ref: React.Ref) => (
@@ -121,6 +127,26 @@ const useToolbarStyles = makeStyles(theme => ({
},
}));
+const useFilterStyles = makeStyles(() => ({
+ root: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ title: {
+ fontWeight: 'bold',
+ fontSize: 18,
+ whiteSpace: 'nowrap',
+ },
+}));
+
+const useTableStyles = makeStyles(() => ({
+ root: {
+ display: 'flex',
+ alignItems: 'start',
+ },
+}));
+
function convertColumns(
columns: TableColumn[],
theme: BackstageTheme,
@@ -148,10 +174,16 @@ export interface TableColumn extends Column {
width?: string;
}
+export type TableFilter = {
+ column: string;
+ type: 'select' | 'multiple-select' | 'checkbox-tree';
+};
+
export interface TableProps
extends MaterialTableProps {
columns: TableColumn[];
subtitle?: string;
+ filters?: TableFilter[];
}
export function Table({
@@ -159,12 +191,23 @@ export function Table({
options,
title,
subtitle,
+ filters,
...props
}: TableProps) {
const headerClasses = useHeaderStyles();
const toolbarClasses = useToolbarStyles();
+ const tableClasses = useTableStyles();
+ const filtersClasses = useFilterStyles();
+
+ const { data, ...propsWithoutData } = props;
+
const theme = useTheme();
+ const [filtersOpen, toggleFilters] = useState(false);
+ const [selectedFiltersLength, setSelectedFiltersLength] = useState(0);
+ const [tableData, setTableData] = useState(data as any[]);
+ const [selectedFilters, setSelectedFilters] = useState();
+
const MTColumns = convertColumns(columns, theme);
const defaultOptions: Options = {
@@ -173,30 +216,114 @@ export function Table({
},
};
+ const getFieldByTitle = useCallback(
+ (titleValue: string | keyof T) =>
+ columns.find(el => el.title === titleValue)?.field,
+ [columns],
+ );
+
+ useEffect(() => {
+ if (!selectedFilters) {
+ setTableData(data as any[]);
+ return;
+ }
+
+ const selectedFiltersArray = Object.values(selectedFilters);
+ if (selectedFiltersArray.flat().length) {
+ const newData = (data as any[]).filter(
+ el =>
+ !!Object.entries(selectedFilters)
+ .filter(([, value]) => !!value.length)
+ .every(([key, value]) => {
+ if (Array.isArray(value)) {
+ return value.includes(el[getFieldByTitle(key)]);
+ }
+ return el[getFieldByTitle(key)] === value;
+ }),
+ );
+ setTableData(newData);
+ } else {
+ setTableData(data as any[]);
+ }
+ setSelectedFiltersLength(selectedFiltersArray.flat().length);
+ }, [data, selectedFilters, getFieldByTitle]);
+
+ const constructFilters = (filterConfig: TableFilter[], dataValue: any[]) => {
+ const extractColumnData = (column: string | keyof T) =>
+ dataValue.map(el => ({ label: el[column], options: [] }));
+
+ return filterConfig.map(filter => ({
+ type: filter.type,
+ element:
+ filter.type === 'checkbox-tree'
+ ? {
+ label: filter.column,
+ subCategories: extractColumnData(
+ getFieldByTitle(filter.column) || '',
+ ),
+ }
+ : {
+ placeholder: 'All results',
+ label: filter.column,
+ multiple: filter.type === 'multiple-select',
+ items: dataValue.map(el => ({
+ label: el[getFieldByTitle(filter.column) || ''],
+ value: el[getFieldByTitle(filter.column) || ''],
+ })),
+ },
+ }));
+ };
+
return (
-
- components={{
- Header: headerProps => (
-
- ),
- Toolbar: toolbarProps => (
-
- ),
- }}
- options={{ ...defaultOptions, ...options }}
- columns={MTColumns}
- icons={tableIcons}
- title={
- <>
- {title}
- {subtitle && (
-
- {subtitle}
-
- )}
- >
- }
- {...props}
- />
+
+ {filtersOpen && filters?.length && (
+
+ )}
+
+ components={{
+ Header: headerProps => (
+
+ ),
+ Toolbar: toolbarProps =>
+ filters?.length ? (
+
+
+ toggleFilters(el => !el)}
+ aria-label="filter list"
+ >
+
+
+
+ Filters ({selectedFiltersLength})
+
+
+
+
+ ) : (
+
+ ),
+ }}
+ options={{ ...defaultOptions, ...options }}
+ columns={MTColumns}
+ icons={tableIcons}
+ title={
+ <>
+ {title}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ >
+ }
+ data={tableData}
+ style={{ width: '100%' }}
+ {...propsWithoutData}
+ />
+
);
}
diff --git a/packages/core/src/components/Tabs/Tabs.stories.tsx b/packages/core/src/components/Tabs/Tabs.stories.tsx
index 930a825392..00819c44f1 100644
--- a/packages/core/src/components/Tabs/Tabs.stories.tsx
+++ b/packages/core/src/components/Tabs/Tabs.stories.tsx
@@ -19,7 +19,7 @@ import { Tabs } from './Tabs';
import AccessAlarmIcon from '@material-ui/icons/AccessAlarm';
export default {
- title: 'Tabs',
+ title: 'Navigation/Tabs',
component: Tabs,
};
diff --git a/packages/core/src/components/TrendLine/TrendLine.stories.tsx b/packages/core/src/components/TrendLine/TrendLine.stories.tsx
index cc2fdda052..4eb74b2f17 100644
--- a/packages/core/src/components/TrendLine/TrendLine.stories.tsx
+++ b/packages/core/src/components/TrendLine/TrendLine.stories.tsx
@@ -20,7 +20,7 @@ import { TrendLine } from './TrendLine';
import { InfoCard } from '../../layout/InfoCard';
export default {
- title: 'TrendLine',
+ title: 'Data Display/TrendLine',
component: TrendLine,
};
diff --git a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx
index 92487ed8c5..5098f0aa31 100644
--- a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx
+++ b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx
@@ -19,7 +19,7 @@ import { WarningPanel } from './WarningPanel';
import { Link, Button } from '@material-ui/core';
export default {
- title: 'Warning Panel',
+ title: 'Feedback/Warning Panel',
component: WarningPanel,
};
diff --git a/packages/core/src/components/stories/Chip.stories.tsx b/packages/core/src/components/stories/Chip.stories.tsx
index c0e644fcc5..a605666482 100644
--- a/packages/core/src/components/stories/Chip.stories.tsx
+++ b/packages/core/src/components/stories/Chip.stories.tsx
@@ -18,7 +18,7 @@ import React from 'react';
import { Chip } from '@material-ui/core';
export default {
- title: 'Chip',
+ title: 'Data Display/Chip',
component: Chip,
};
diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx
index 71a24652ba..3d80955cc9 100644
--- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx
+++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx
@@ -65,7 +65,12 @@ export const ErrorPage = ({
Go back
... or if you think this is a bug, please file an{' '}
- issue.
+
+ issue.
+
diff --git a/packages/core/src/layout/Header/Header.stories.tsx b/packages/core/src/layout/Header/Header.stories.tsx
index 39b9133c0a..91045d4bb4 100644
--- a/packages/core/src/layout/Header/Header.stories.tsx
+++ b/packages/core/src/layout/Header/Header.stories.tsx
@@ -19,7 +19,7 @@ import { HeaderLabel } from '../HeaderLabel';
import { Page, pageTheme } from '../Page';
export default {
- title: 'Header',
+ title: 'Layout/Header',
component: Header,
};
diff --git a/packages/core/src/layout/Header/Header.test.tsx b/packages/core/src/layout/Header/Header.test.tsx
index 5d28c6633f..320592c218 100644
--- a/packages/core/src/layout/Header/Header.test.tsx
+++ b/packages/core/src/layout/Header/Header.test.tsx
@@ -58,4 +58,11 @@ describe('', () => {
);
rendered.getByText('tool');
});
+
+ it('should have breadcrumb rendered', () => {
+ const rendered = render(
+ wrapInTestApp(),
+ );
+ rendered.getAllByText('Title');
+ });
});
diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx
index 6b9ea05245..3bd95d0ef3 100644
--- a/packages/core/src/layout/Header/Header.tsx
+++ b/packages/core/src/layout/Header/Header.tsx
@@ -16,9 +16,15 @@
import React, { ReactNode, CSSProperties, FC, useContext } from 'react';
import { Helmet } from 'react-helmet';
-import { Typography, Tooltip, makeStyles } from '@material-ui/core';
+import {
+ Link,
+ Typography,
+ Tooltip,
+ makeStyles,
+ Breadcrumbs,
+} from '@material-ui/core';
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import { BackstageTheme } from '@backstage/theme';
-
import { PageThemeContext } from '../Page/Page';
const useStyles = makeStyles(
@@ -69,6 +75,21 @@ const useStyles = makeStyles(
marginBottom: theme.spacing(1),
color: theme.palette.bursts.fontColor,
},
+ breadcrumb: {
+ fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))',
+ color: theme.palette.bursts.fontColor,
+ },
+ breadcrumbType: {
+ fontSize: 'inherit',
+ opacity: 0.7,
+ marginRight: -theme.spacing(0.3),
+ marginBottom: theme.spacing(0.3),
+ },
+ breadcrumbTitle: {
+ fontSize: 'inherit',
+ marginLeft: -theme.spacing(0.3),
+ marginBottom: theme.spacing(0.3),
+ },
}),
);
@@ -87,7 +108,8 @@ type Props = {
type TypeFragmentProps = {
classes: HeaderStyles;
- type?: Props['title'];
+ pageTitle: string | ReactNode;
+ type?: Props['type'];
typeLink?: Props['typeLink'];
};
@@ -102,17 +124,32 @@ type SubtitleFragmentProps = {
subtitle?: Props['subtitle'];
};
-const TypeFragment: FC = ({ type, typeLink, classes }) => {
+const TypeFragment: FC = ({
+ type,
+ typeLink,
+ classes,
+ pageTitle,
+}) => {
if (!type) {
return null;
}
if (!typeLink) {
- // TODO: Add breadcrumbs.
return {type} ;
}
- return {type} ;
+ return (
+ }
+ className={classes.breadcrumb}
+ >
+
+ {type}
+
+ {pageTitle}
+
+ );
};
const TitleFragment: FC = ({
@@ -175,7 +212,12 @@ export const Header: FC = ({
-
+
{
- const configApi = useApi(configApiRef);
- const providersConfig = configApi.getOptionalConfig('auth.providers');
- const providers = providersConfig?.keys() ?? [];
-
- return (
- <>
- {providers.includes('google') && (
-
- )}
- {providers.includes('microsoft') && (
-
- )}
- {providers.includes('github') && (
-
- )}
- {providers.includes('gitlab') && (
-
- )}
- {providers.includes('okta') && (
-
- )}
- {providers.includes('saml') && (
-
- )}
- {providers.includes('oauth2') && (
-
- )}
- >
- );
-};
diff --git a/packages/core/src/layout/Sidebar/Settings/SettingsDialog.tsx b/packages/core/src/layout/Sidebar/Settings/SettingsDialog.tsx
deleted file mode 100644
index 71c4899862..0000000000
--- a/packages/core/src/layout/Sidebar/Settings/SettingsDialog.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import React from 'react';
-import {
- Card,
- CardContent,
- CardHeader,
- makeStyles,
- Divider,
-} from '@material-ui/core';
-import { AppSettingsList } from './AppSettingsList';
-import { AuthProvidersList } from './AuthProviderList';
-import { FeatureFlagsList } from './FeatureFlagsList';
-import { SignInAvatar } from './SignInAvatar';
-import { UserSettingsMenu } from './UserSettingsMenu';
-import { useUserProfile } from './useUserProfileInfo';
-import { useApi, featureFlagsApiRef } from '@backstage/core-api';
-
-const useStyles = makeStyles({
- root: {
- minWidth: 400,
- },
-});
-
-type Props = {
- providerSettings?: React.ReactNode;
-};
-
-export const SettingsDialog = ({ providerSettings }: Props) => {
- const classes = useStyles();
- const { profile, displayName } = useUserProfile();
- const featureFlagsApi = useApi(featureFlagsApiRef);
- const featureFlags = featureFlagsApi.getRegisteredFlags();
-
- return (
-
- }
- action={ }
- title={displayName}
- subheader={profile.email}
- />
-
-
- {providerSettings && (
- <>
-
-
- >
- )}
- {featureFlags.length > 0 && (
- <>
-
-
- >
- )}
-
-
- );
-};
diff --git a/packages/core/src/layout/Sidebar/Settings/UserSettings.tsx b/packages/core/src/layout/Sidebar/Settings/UserSettings.tsx
deleted file mode 100644
index 85dbefff9b..0000000000
--- a/packages/core/src/layout/Sidebar/Settings/UserSettings.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import React, { useEffect, useContext } from 'react';
-import { Popover } from '@material-ui/core';
-import { SignInAvatar } from './SignInAvatar';
-import { SettingsDialog } from './SettingsDialog';
-import { SidebarItem } from '../Items';
-import { useUserProfile } from './useUserProfileInfo';
-import { SidebarContext } from '../config';
-
-type Props = {
- providerSettings?: React.ReactNode;
-};
-
-export const SidebarUserSettings = ({ providerSettings }: Props) => {
- const { isOpen: sidebarOpen } = useContext(SidebarContext);
- const { displayName } = useUserProfile();
- const [open, setOpen] = React.useState(false);
- const [anchorEl, setAnchorEl] = React.useState(
- undefined,
- );
-
- const handleOpen = (event?: React.MouseEvent) => {
- setAnchorEl(event?.currentTarget ?? undefined);
- setOpen(true);
- };
-
- const handleClose = () => {
- setAnchorEl(undefined);
- setOpen(false);
- };
-
- useEffect(() => {
- if (!sidebarOpen && open) setOpen(false);
- }, [open, sidebarOpen]);
-
- const SidebarAvatar = () => ;
-
- return (
- <>
-
-
-
-
- >
- );
-};
diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
index 61975a728a..d6a01fea33 100644
--- a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
+++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
@@ -22,17 +22,13 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
- SidebarUserSettings,
- ProviderSettingsItem,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
-import Star from '@material-ui/icons/Star';
import { MemoryRouter } from 'react-router-dom';
-import { githubAuthApiRef } from '@backstage/core-api';
export default {
- title: 'Sidebar',
+ title: 'Layout/Sidebar',
component: Sidebar,
decorators: [
(storyFn: () => JSX.Element) => (
@@ -48,7 +44,6 @@ const handleSearch = (input: string) => {
export const SampleSidebar = () => (
- {/* */}
@@ -57,15 +52,5 @@ export const SampleSidebar = () => (
-
-
- }
- />
);
diff --git a/packages/core/src/layout/Sidebar/index.ts b/packages/core/src/layout/Sidebar/index.ts
index a644c06e14..803306478d 100644
--- a/packages/core/src/layout/Sidebar/index.ts
+++ b/packages/core/src/layout/Sidebar/index.ts
@@ -31,5 +31,3 @@ export {
sidebarConfig,
} from './config';
export type { SidebarContextType } from './config';
-export { DefaultProviderSettings } from './DefaultProviderSettings';
-export * from './Settings';
diff --git a/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx b/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx
index 23c2ab5b49..f4af6dcf9f 100644
--- a/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx
+++ b/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx
@@ -20,7 +20,7 @@ import { Grid } from '@material-ui/core';
const cardContentStyle = { height: 200, width: 500 };
export default {
- title: 'Tabbed Card',
+ title: 'Layout/Tabbed Card',
component: TabbedCard,
decorators: [
(storyFn: () => JSX.Element) => (
diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts
index 28e3805ce8..4f25dc9b9e 100644
--- a/packages/create-app/src/createApp.ts
+++ b/packages/create-app/src/createApp.ts
@@ -74,7 +74,6 @@ async function buildApp(appDir: string) {
await runCmd('yarn install');
await runCmd('yarn tsc');
- await runCmd('yarn build');
}
async function moveApp(tempDir: string, destination: string, id: string) {
@@ -148,6 +147,10 @@ export default async (cmd: Command): Promise => {
chalk.green(`🥇 Successfully created ${chalk.cyan(answers.name)}`),
);
Task.log();
+ Task.log(
+ 'See https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers',
+ );
+ Task.log();
Task.exit();
} catch (error) {
Task.error(error.message);
diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs
index fb6185d51b..7175ea0e4f 100644
--- a/packages/create-app/templates/default-app/app-config.yaml.hbs
+++ b/packages/create-app/templates/default-app/app-config.yaml.hbs
@@ -50,12 +50,13 @@ lighthouse:
baseUrl: http://localhost:3003
auth:
+ # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers
providers: {}
scaffolder:
github:
token:
- $env: GITHUB_ACCESS_TOKEN
+ $env: GITHUB_TOKEN
visibility: public # or 'internal' or 'private'
catalog:
@@ -66,12 +67,12 @@ catalog:
providers:
- target: https://github.com
token:
- $env: GITHUB_PRIVATE_TOKEN
+ $env: GITHUB_TOKEN
# Example for how to add your GitHub Enterprise instance:
# - target: https://ghe.example.net
# apiBaseUrl: https://ghe.example.net/api/v3
# token:
- # $env: GHE_PRIVATE_TOKEN
+ # $env: GHE_TOKEN
locations:
# Backstage example components
- type: url
@@ -82,23 +83,23 @@ catalog:
target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml
# Backstage example templates
- - type: url
+ - type: github
target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml
rules:
- allow: [Template]
- - type: url
+ - type: github
target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml
rules:
- allow: [Template]
- - type: url
+ - type: github
target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml
rules:
- allow: [Template]
- - type: url
+ - type: github
target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml
rules:
- allow: [Template]
- - type: url
+ - type: github
target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml
rules:
- allow: [Template]
diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs
index a057826ba4..103e75f6e0 100644
--- a/packages/create-app/templates/default-app/packages/app/package.json.hbs
+++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs
@@ -19,6 +19,7 @@
"@backstage/plugin-lighthouse": "^{{version}}",
"@backstage/plugin-tech-radar": "^{{version}}",
"@backstage/plugin-github-actions": "^{{version}}",
+ "@backstage/plugin-user-settings": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"history": "^5.0.0",
diff --git a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx
index 9dd9ea64c3..901347645e 100644
--- a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx
+++ b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx
@@ -9,6 +9,7 @@ import { Link, makeStyles } from '@material-ui/core';
import { NavLink } from 'react-router-dom';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
+import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import {
Sidebar,
@@ -17,8 +18,6 @@ import {
sidebarConfig,
SidebarContext,
SidebarSpace,
- SidebarUserSettings,
- DefaultProviderSettings,
} from '@backstage/core';
export const AppSidebar = () => (
@@ -37,7 +36,7 @@ export const AppSidebar = () => (
- } />
+
);
diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts
index 684db8aab8..d58ebe4e42 100644
--- a/packages/create-app/templates/default-app/packages/backend/src/index.ts
+++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts
@@ -8,17 +8,16 @@
import Router from 'express-promise-router';
import {
- ensureDatabaseExists,
- createDatabaseClient,
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
notFoundHandler,
+ SingleConnectionDatabaseManager,
SingleHostDiscovery,
UrlReaders,
} from '@backstage/backend-common';
-import { ConfigReader, AppConfig } from '@backstage/config';
+import { ConfigReader } from '@backstage/config';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
import scaffolder from './plugins/scaffolder';
@@ -26,24 +25,18 @@ import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import { PluginEnvironment } from './types';
-function makeCreateEnv(loadedConfigs: AppConfig[]) {
- const config = ConfigReader.fromConfigs(loadedConfigs);
+function makeCreateEnv(config: ConfigReader) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = SingleHostDiscovery.fromConfig(config);
root.info(`Created UrlReader ${reader}`);
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
+
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
- const database = createDatabaseClient(
- config.getConfig('backend.database'),
- {
- connection: {
- database: `backstage_plugin_${plugin}`,
- },
- },
- );
+ const database = databaseManager.forPlugin(plugin);
return { logger, database, config, reader, discovery };
};
}
@@ -51,12 +44,7 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
async function main() {
const configs = await loadBackendConfig();
const configReader = ConfigReader.fromConfigs(configs);
- const createEnv = makeCreateEnv(configs);
- await ensureDatabaseExists(
- configReader.getConfig('backend.database'),
- 'backstage_plugin_catalog',
- 'backstage_plugin_auth',
- );
+ const createEnv = makeCreateEnv(configReader);
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts
index 345ac4c23d..aee38a5fa0 100644
--- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts
+++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts
@@ -18,7 +18,9 @@ export default async function createPlugin({
}: PluginEnvironment) {
const locationReader = new LocationReaders({ logger, reader, config });
- const db = await DatabaseManager.createDatabase(database, { logger });
+ const db = await DatabaseManager.createDatabase(await database.getClient(),
+ { logger },
+ );
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts
index aa003f63a5..757a0e5acf 100644
--- a/packages/create-app/templates/default-app/packages/backend/src/types.ts
+++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts
@@ -1,11 +1,14 @@
-import Knex from 'knex';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
-import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common';
+import {
+ PluginDatabaseManager,
+ PluginEndpointDiscovery,
+ UrlReader,
+} from '@backstage/backend-common';
export type PluginEnvironment = {
logger: Logger;
- database: Knex;
+ database: PluginDatabaseManager;
config: Config;
reader: UrlReader
discovery: PluginEndpointDiscovery;
diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts
index d1529d3af2..104a481d46 100644
--- a/packages/e2e-test/src/e2e-test.ts
+++ b/packages/e2e-test/src/e2e-test.ts
@@ -285,7 +285,7 @@ async function testAppServe(pluginName: string, appDir: string) {
cwd: appDir,
env: {
...process.env,
- GITHUB_ACCESS_TOKEN: 'abc',
+ GITHUB_TOKEN: 'abc',
},
});
Browser.localhost('localhost', 3000);
@@ -328,8 +328,8 @@ async function testAppServe(pluginName: string, appDir: string) {
}
}
-/** Creates PG databases (drops if exists before) */
-async function createDB(database: string) {
+/** Drops PG databases */
+async function dropDB(database: string) {
const config = {
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT,
@@ -342,7 +342,6 @@ async function createDB(database: string) {
} catch (_) {
/* do nothing*/
}
- return pgtools.createdb(config, database);
}
/**
@@ -350,7 +349,7 @@ async function createDB(database: string) {
*/
async function testBackendStart(appDir: string, isPostgres: boolean) {
if (isPostgres) {
- print('Creating DBs');
+ print('Dropping old DBs');
await Promise.all(
[
'catalog',
@@ -359,7 +358,7 @@ async function testBackendStart(appDir: string, isPostgres: boolean) {
'identity',
'proxy',
'techdocs',
- ].map(name => createDB(`backstage_plugin_${name}`)),
+ ].map(name => dropDB(`backstage_plugin_${name}`)),
);
print('Created DBs');
}
@@ -368,7 +367,7 @@ async function testBackendStart(appDir: string, isPostgres: boolean) {
cwd: appDir,
env: {
...process.env,
- GITHUB_ACCESS_TOKEN: 'abc',
+ GITHUB_TOKEN: 'abc',
},
});
diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js
index c83bb221be..2c58144acc 100644
--- a/packages/storybook/.storybook/main.js
+++ b/packages/storybook/.storybook/main.js
@@ -5,6 +5,7 @@ module.exports = {
stories: [
'../../core/src/layout/**/*.stories.tsx',
'../../core/src/components/**/*.stories.tsx',
+ '../../../plugins/**/src/**/*.stories.tsx',
],
addons: [
'@storybook/addon-actions',
diff --git a/packages/techdocs-cli/src/index.ts b/packages/techdocs-cli/src/index.ts
index b48f03d35b..da196d10b8 100644
--- a/packages/techdocs-cli/src/index.ts
+++ b/packages/techdocs-cli/src/index.ts
@@ -20,11 +20,7 @@ import path from 'path';
import HTTPServer from './lib/httpServer';
import openBrowser from 'react-dev-utils/openBrowser';
-const run = (
- workingDirectory: string,
- name: string,
- args: string[] = [],
-): ChildProcess => {
+const run = (name: string, args: string[] = []): ChildProcess => {
const [stdin, stdout, stderr] = [
'inherit' as const,
'pipe' as const,
@@ -32,7 +28,6 @@ const run = (
];
const childProcess = spawn(name, args, {
- cwd: workingDirectory,
stdio: [stdin, stdout, stderr],
shell: true,
env: {
@@ -59,13 +54,13 @@ const runMkdocsServer = (options?: {
const devAddr = options?.devAddr ?? '0.0.0.0:8000';
return new Promise(resolve => {
- const childProcess = run(process.env.PWD!, 'docker', [
+ const childProcess = run('docker', [
'run',
'-it',
'-w',
'/content',
'-v',
- '$(pwd):/content',
+ `${process.cwd()}:/content`,
'-p',
'8000:8000',
'spotify/techdocs',
diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts
index bcd1fc1794..6bb100de6b 100644
--- a/plugins/auth-backend/src/service/router.ts
+++ b/plugins/auth-backend/src/service/router.ts
@@ -17,7 +17,6 @@
import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
-import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProvider } from '../providers';
import { Config } from '@backstage/config';
@@ -25,11 +24,12 @@ import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
import {
NotFoundError,
PluginEndpointDiscovery,
+ PluginDatabaseManager,
} from '@backstage/backend-common';
export interface RouterOptions {
logger: Logger;
- database: Knex;
+ database: PluginDatabaseManager;
config: Config;
discovery: PluginEndpointDiscovery;
}
@@ -47,7 +47,9 @@ export async function createRouter({
const keyDurationSeconds = 3600;
- const keyStore = await DatabaseKeyStore.create({ database });
+ const keyStore = await DatabaseKeyStore.create({
+ database: await database.getClient(),
+ });
const tokenIssuer = new TokenFactory({
issuer: authUrl,
keyStore,
diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts
index 71771340a5..d00e7d523d 100644
--- a/plugins/auth-backend/src/service/standaloneServer.ts
+++ b/plugins/auth-backend/src/service/standaloneServer.ts
@@ -53,7 +53,11 @@ export async function startStandaloneServer(
const router = await createRouter({
logger,
config,
- database,
+ database: {
+ async getClient() {
+ return database;
+ },
+ },
discovery,
});
diff --git a/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js b/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js
new file mode 100644
index 0000000000..fdabf72fce
--- /dev/null
+++ b/plugins/catalog-backend/migrations/20201006130744_entity_data_column.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.up = async function up(knex) {
+ await knex.schema.alterTable('entities', table => {
+ table
+ .text('data')
+ .nullable()
+ .comment('The entire JSON data blob of the entity');
+ });
+
+ await knex('entities').update({
+ // apiVersion and kind should not contain any JSON unsafe chars, and both
+ // metadata and spec are already valid serialized JSON
+ data: knex.raw(
+ `'{"apiVersion":"' || api_version || '","kind":"' || kind || '","metadata":' || metadata || COALESCE(',"spec":' || spec, '') || '}'`,
+ ),
+ });
+
+ await knex.schema.alterTable('entities', table => {
+ table.dropColumn('metadata');
+ table.dropColumn('spec');
+ });
+
+ // SQLite does not support ALTER COLUMN. Note that we do not use the try/
+ // catch method as in other migrations, because if the transaction is
+ // partially failed, it will further mess up the already messed-up
+ // statement below this.
+ if (knex.client.config.client !== 'sqlite3') {
+ await knex.schema.alterTable('entities', table => {
+ table.text('data').notNullable().alter();
+ });
+ }
+
+ // NOTE(freben): For some reason, specifically sqlite3 in-mem just drops some
+ // subset of constraints sometimes, when a table column is dropped - even if
+ // the column had no relation at all to the constraint. We therefore recreate
+ // the constraint here as a stupid fix.
+ if (knex.client.config.client === 'sqlite3') {
+ await knex.schema.alterTable('entities', table => {
+ table.unique(['full_name'], 'entities_unique_full_name');
+ });
+ }
+};
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.alterTable('entities', table => {
+ table
+ .text('metadata')
+ .notNullable()
+ .comment('The entire metadata JSON blob of the entity');
+ table
+ .text('spec')
+ .nullable()
+ .comment('The entire spec JSON blob of the entity');
+ table.dropColumn('data');
+ });
+};
diff --git a/plugins/catalog-backend/migrations/20201006203131_entity_remove_redundant_columns.js b/plugins/catalog-backend/migrations/20201006203131_entity_remove_redundant_columns.js
new file mode 100644
index 0000000000..7bacc67338
--- /dev/null
+++ b/plugins/catalog-backend/migrations/20201006203131_entity_remove_redundant_columns.js
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.up = async function up(knex) {
+ await knex.schema.alterTable('entities', table => {
+ table.dropColumn('api_version');
+ table.dropColumn('kind');
+ table.dropColumn('name');
+ table.dropColumn('namespace');
+ });
+
+ // NOTE(freben): For some reason, specifically sqlite3 in-mem just drops some
+ // subset of constraints sometimes, when a table column is dropped - even if
+ // the column had no relation at all to the constraint. We therefore recreate
+ // the constraint here as a stupid fix.
+ if (knex.client.config.client === 'sqlite3') {
+ await knex.schema.alterTable('entities', table => {
+ table.unique(['full_name'], 'entities_unique_full_name');
+ });
+ }
+};
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.alterTable('entities', table => {
+ table
+ .string('api_version')
+ .notNullable()
+ .comment('The apiVersion field of the entity');
+ table.string('kind').notNullable().comment('The kind field of the entity');
+ table
+ .string('name')
+ .nullable()
+ .comment('The metadata.name field of the entity');
+ table
+ .string('namespace')
+ .nullable()
+ .comment('The metadata.namespace field of the entity');
+ });
+};
diff --git a/plugins/catalog-backend/migrations/20201007201501_index_entity_search.js b/plugins/catalog-backend/migrations/20201007201501_index_entity_search.js
new file mode 100644
index 0000000000..b902073e64
--- /dev/null
+++ b/plugins/catalog-backend/migrations/20201007201501_index_entity_search.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.up = async function up(knex) {
+ await knex.schema.alterTable('entities_search', table => {
+ table.index(['key'], 'entities_search_key');
+ table.index(['value'], 'entities_search_value');
+ });
+};
+
+/**
+ * @param {import('knex')} knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.alterTable('entities_search', table => {
+ table.dropIndex('', 'entities_search_key');
+ table.dropIndex('', 'entities_search_value');
+ });
+};
diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json
index 4a3b2c7442..bbd86f9d60 100644
--- a/plugins/catalog-backend/package.json
+++ b/plugins/catalog-backend/package.json
@@ -31,11 +31,12 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
- "git-url-parse": "^11.2.0",
+ "git-url-parse": "^11.3.0",
"knex": "^0.21.1",
"ldapjs": "^2.2.0",
"lodash": "^4.17.15",
"morgan": "^1.10.0",
+ "p-limit": "^3.0.2",
"sqlite3": "^5.0.0",
"uuid": "^8.0.0",
"winston": "^3.2.1",
diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts
deleted file mode 100644
index 1cfea23e43..0000000000
--- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
-import { Logger } from 'winston';
-import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog';
-import { EntitiesCatalog } from './types';
-
-describe('CoalescedEntitiesCatalog', () => {
- const e1: Entity = {
- apiVersion: 'a',
- kind: 'k',
- metadata: { name: 'n1' },
- };
-
- const e2: Entity = {
- apiVersion: 'a',
- kind: 'k',
- metadata: { name: 'n2' },
- };
-
- const c1: jest.Mocked = {
- entities: jest.fn(),
- entityByUid: jest.fn(),
- entityByName: jest.fn(),
- addOrUpdateEntity: jest.fn(),
- removeEntityByUid: jest.fn(),
- };
-
- const c2: jest.Mocked = {
- entities: jest.fn(),
- entityByUid: jest.fn(),
- entityByName: jest.fn(),
- addOrUpdateEntity: jest.fn(),
- removeEntityByUid: jest.fn(),
- };
-
- const mockLogger = {
- warn: jest.fn(),
- };
- const logger = (mockLogger as unknown) as Logger;
-
- beforeEach(() => {
- jest.resetAllMocks();
- });
-
- describe('entities', () => {
- it('flattens results from multiple sources', async () => {
- c1.entities.mockResolvedValueOnce([e1]);
- c2.entities.mockResolvedValueOnce([e2]);
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(catalog.entities()).resolves.toEqual(
- expect.arrayContaining([e1, e2]),
- );
- expect(c1.entities).toBeCalledTimes(1);
- expect(c2.entities).toBeCalledTimes(1);
- });
-
- it('logs an error if any source throws', async () => {
- c1.entities.mockResolvedValueOnce([e1]);
- c2.entities.mockRejectedValueOnce(new Error('boo'));
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(catalog.entities()).resolves.toEqual([e1]);
- expect(c1.entities).toBeCalledTimes(1);
- expect(c2.entities).toBeCalledTimes(1);
- expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
- });
- });
-
- describe('entityByUid', () => {
- it('returns the first non-undefined result', async () => {
- c1.entityByUid.mockResolvedValueOnce(undefined);
- c2.entityByUid.mockResolvedValueOnce(e2);
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(catalog.entityByUid('e2')).resolves.toBe(e2);
- expect(c1.entityByUid).toBeCalledTimes(1);
- expect(c2.entityByUid).toBeCalledTimes(1);
- });
-
- it('returns undefined if all results were undefined', async () => {
- c1.entityByUid.mockResolvedValueOnce(undefined);
- c2.entityByUid.mockResolvedValueOnce(undefined);
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(catalog.entityByUid('e2')).resolves.toBeUndefined();
- expect(c1.entityByUid).toBeCalledTimes(1);
- expect(c2.entityByUid).toBeCalledTimes(1);
- });
-
- it('logs an error if any source throws', async () => {
- c1.entityByUid.mockResolvedValueOnce(e1);
- c2.entityByUid.mockRejectedValueOnce(new Error('boo'));
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(catalog.entityByUid('e2')).resolves.toBe(e1);
- expect(c1.entityByUid).toBeCalledTimes(1);
- expect(c2.entityByUid).toBeCalledTimes(1);
- expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
- });
- });
-
- describe('entityByName', () => {
- it('returns the first non-undefined result', async () => {
- c1.entityByName.mockResolvedValueOnce(undefined);
- c2.entityByName.mockResolvedValueOnce(e2);
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(
- catalog.entityByName({
- kind: 'k',
- namespace: ENTITY_DEFAULT_NAMESPACE,
- name: 'n2',
- }),
- ).resolves.toBe(e2);
- expect(c1.entityByName).toBeCalledTimes(1);
- expect(c2.entityByName).toBeCalledTimes(1);
- });
-
- it('returns undefined if all results were undefined', async () => {
- c1.entityByName.mockResolvedValueOnce(undefined);
- c2.entityByName.mockResolvedValueOnce(undefined);
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(
- catalog.entityByName({
- kind: 'k',
- namespace: ENTITY_DEFAULT_NAMESPACE,
- name: 'n2',
- }),
- ).resolves.toBeUndefined();
- expect(c1.entityByName).toBeCalledTimes(1);
- expect(c2.entityByName).toBeCalledTimes(1);
- });
-
- it('logs an error if any source throws', async () => {
- c1.entityByName.mockResolvedValueOnce(e1);
- c2.entityByName.mockRejectedValueOnce(new Error('boo'));
- const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
- await expect(
- catalog.entityByName({
- kind: 'k',
- namespace: ENTITY_DEFAULT_NAMESPACE,
- name: 'n2',
- }),
- ).resolves.toBe(e1);
- expect(c1.entityByName).toBeCalledTimes(1);
- expect(c2.entityByName).toBeCalledTimes(1);
- expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
- });
- });
-});
diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts
deleted file mode 100644
index 7eaf1868cb..0000000000
--- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { Entity, EntityName } from '@backstage/catalog-model';
-import { Logger } from 'winston';
-import { EntityFilters } from '../database';
-import { EntitiesCatalog } from './types';
-
-/**
- * A simple coalescing catalog wrapper, that acts as a front for collecting
- * catalog data from multiple sources.
- *
- * One possible usage could be to have this as a front to both a
- * DatabaseEntitiesCatalog that holds Component kinds, and another company-
- * specific catalog that is a thin wrapper on top of LDAP that supplies Group
- * and User entities. That way you'll get a coherent view of two very different
- * entity sources.
- *
- * This is mainly meant as a functional example, and you may want to provide
- * your own more specialized collector if you have this distinct need. This
- * one does not support adding/updating entities through the API for example.
- * A more competent implementation may direct the writes to different catalogs
- * based on entity kind or similar.
- */
-export class CoalescedEntitiesCatalog implements EntitiesCatalog {
- private inner: EntitiesCatalog[];
- private logger: Logger;
-
- constructor(inner: EntitiesCatalog[], logger: Logger) {
- this.inner = inner;
- this.logger = logger;
- }
-
- async entities(filters?: EntityFilters): Promise {
- const ops = this.inner.map(async catalog => {
- try {
- return await catalog.entities(filters);
- } catch (e) {
- this.logger.warn(`Inner entities call failed, ${e}`);
- return [];
- }
- });
-
- const results = await Promise.all(ops);
- return results.flat();
- }
-
- async entityByUid(uid: string): Promise {
- const ops = this.inner.map(async catalog => {
- try {
- return await catalog.entityByUid(uid);
- } catch (e) {
- this.logger.warn(`Inner entityByUid call failed, ${e}`);
- return undefined;
- }
- });
-
- const results = await Promise.all(ops);
- return results.find(Boolean);
- }
-
- async entityByName(name: EntityName): Promise {
- const ops = this.inner.map(async catalog => {
- try {
- return await catalog.entityByName(name);
- } catch (e) {
- this.logger.warn(`Inner entityByName call failed, ${e}`);
- return undefined;
- }
- });
-
- const results = await Promise.all(ops);
- return results.find(Boolean);
- }
-
- addOrUpdateEntity(): Promise {
- throw new Error('Method not implemented.');
- }
-
- removeEntityByUid(): Promise {
- throw new Error('Method not implemented.');
- }
-}
diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts
index b94365a9ba..8ef685e6b0 100644
--- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts
+++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts
@@ -24,12 +24,12 @@ describe('DatabaseEntitiesCatalog', () => {
beforeAll(() => {
db = {
transaction: jest.fn(),
- addEntity: jest.fn(),
+ addEntities: jest.fn(),
updateEntity: jest.fn(),
entities: jest.fn(),
entityByName: jest.fn(),
entityByUid: jest.fn(),
- removeEntity: jest.fn(),
+ removeEntityByUid: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
location: jest.fn(),
@@ -56,7 +56,7 @@ describe('DatabaseEntitiesCatalog', () => {
};
db.entities.mockResolvedValue([]);
- db.addEntity.mockResolvedValue({ entity });
+ db.addEntities.mockResolvedValue([{ entity }]);
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(entity);
@@ -67,7 +67,7 @@ describe('DatabaseEntitiesCatalog', () => {
namespace: 'd',
name: 'c',
});
- expect(db.addEntity).toHaveBeenCalledTimes(1);
+ expect(db.addEntities).toHaveBeenCalledTimes(1);
expect(result).toBe(entity);
});
diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts
index 750d728c67..2575411e7c 100644
--- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts
+++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts
@@ -72,13 +72,25 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
existing.entity.metadata.generation,
);
} else {
- response = await this.database.addEntity(tx, { locationId, entity });
+ const added = await this.database.addEntities(tx, [
+ { locationId, entity },
+ ]);
+ response = added[0];
}
return response.entity;
});
}
+ async addEntities(entities: Entity[], locationId?: string): Promise {
+ await this.database.transaction(async tx => {
+ await this.database.addEntities(
+ tx,
+ entities.map(entity => ({ locationId, entity })),
+ );
+ });
+ }
+
async removeEntityByUid(uid: string): Promise {
return await this.database.transaction(async tx => {
const entityResponse = await this.database.entityByUid(tx, uid);
@@ -96,7 +108,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
- await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
+ await this.database.removeEntityByUid(
+ tx,
+ dbResponse?.entity.metadata.uid!,
+ );
}
if (entityResponse.locationId) {
diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts
deleted file mode 100644
index 65fbfb9071..0000000000
--- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { Entity, EntityName, getEntityName } from '@backstage/catalog-model';
-import lodash from 'lodash';
-import type { EntitiesCatalog } from './types';
-
-export class StaticEntitiesCatalog implements EntitiesCatalog {
- private _entities: Entity[];
-
- constructor(entities: Entity[]) {
- this._entities = entities;
- }
-
- async entities(): Promise {
- return lodash.cloneDeep(this._entities);
- }
-
- async entityByUid(uid: string): Promise {
- const item = this._entities.find(e => uid === e.metadata.uid);
- return item ? lodash.cloneDeep(item) : undefined;
- }
-
- async entityByName(name: EntityName): Promise {
- const item = this._entities.find(e => {
- const candidate = getEntityName(e);
- return (
- name.kind.toLowerCase() === candidate.kind.toLowerCase() &&
- name.namespace.toLowerCase() === candidate.namespace.toLowerCase() &&
- name.name.toLowerCase() === candidate.name.toLowerCase()
- );
- });
- return item ? lodash.cloneDeep(item) : undefined;
- }
-
- async addOrUpdateEntity(): Promise {
- throw new Error('Not supported');
- }
-
- async removeEntityByUid(): Promise {
- throw new Error('Not supported');
- }
-}
diff --git a/plugins/catalog-backend/src/catalog/index.ts b/plugins/catalog-backend/src/catalog/index.ts
index 308078b1fc..ce429327a0 100644
--- a/plugins/catalog-backend/src/catalog/index.ts
+++ b/plugins/catalog-backend/src/catalog/index.ts
@@ -16,5 +16,4 @@
export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
-export { StaticEntitiesCatalog } from './StaticEntitiesCatalog';
export type { EntitiesCatalog, LocationsCatalog } from './types';
diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts
index 37618a2440..5d84cbcebc 100644
--- a/plugins/catalog-backend/src/catalog/types.ts
+++ b/plugins/catalog-backend/src/catalog/types.ts
@@ -26,6 +26,7 @@ export type EntitiesCatalog = {
entityByUid(uid: string): Promise;
entityByName(name: EntityName): Promise;
addOrUpdateEntity(entity: Entity, locationId?: string): Promise;
+ addEntities(entities: Entity[], locationId?: string): Promise;
removeEntityByUid(uid: string): Promise;
};
diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts
index 8a42b39cc5..840af74825 100644
--- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts
+++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts
@@ -17,12 +17,12 @@
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
-import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
DbLocationsRowWithStatus,
} from './types';
+import { Database, DatabaseLocationUpdateLogStatus } from './types';
const bootstrapLocation = {
id: expect.any(String),
@@ -124,7 +124,7 @@ describe('CommonDatabase', () => {
{
...output,
status: DatabaseLocationUpdateLogStatus.FAIL,
- timestamp: expect.any(String),
+ timestamp: expect.anything(),
},
]),
);
@@ -135,36 +135,99 @@ describe('CommonDatabase', () => {
await expect(db.location(location.id)).rejects.toThrow(/Found no location/);
});
- describe('addEntity', () => {
- it('happy path: adds entity to empty database', async () => {
- const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
- expect(added).toStrictEqual(entityResponse);
- expect(added.entity.metadata.generation).toBe(1);
+ describe('addEntities', () => {
+ it('happy path: adds entities to empty database', async () => {
+ const result = await db.transaction(tx =>
+ db.addEntities(tx, [entityRequest]),
+ );
+ expect(result).toEqual([entityResponse]);
});
it('rejects adding the same-named entity twice', async () => {
- await db.transaction(tx => db.addEntity(tx, entityRequest));
+ const req: DbEntityRequest[] = [
+ {
+ entity: {
+ apiVersion: 'av1',
+ kind: 'k1',
+ metadata: { name: 'n1', namespace: 'ns1' },
+ },
+ },
+ {
+ entity: {
+ apiVersion: 'av1',
+ kind: 'k1',
+ metadata: { name: 'n1', namespace: 'ns1' },
+ },
+ },
+ ];
await expect(
- db.transaction(tx => db.addEntity(tx, entityRequest)),
+ db.transaction(tx => db.addEntities(tx, req)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-namespace entity twice', async () => {
- entityRequest.entity.metadata.namespace = undefined;
- await db.transaction(tx => db.addEntity(tx, entityRequest));
- entityRequest.entity.metadata.namespace = '';
+ const req: DbEntityRequest[] = [
+ {
+ entity: {
+ apiVersion: 'av1',
+ kind: 'k1',
+ metadata: { name: 'n1', namespace: 'ns1' },
+ },
+ },
+ {
+ entity: {
+ apiVersion: 'av1',
+ kind: 'k1',
+ metadata: { name: 'n1', namespace: 'nS1' },
+ },
+ },
+ ];
await expect(
- db.transaction(tx => db.addEntity(tx, entityRequest)),
+ db.transaction(tx => db.addEntities(tx, req)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
- entityRequest.entity.metadata.namespace = 'namespace1';
- await db.transaction(tx => db.addEntity(tx, entityRequest));
- entityRequest.entity.metadata.namespace = 'namespace2';
+ const req: DbEntityRequest[] = [
+ {
+ entity: {
+ apiVersion: 'av1',
+ kind: 'k1',
+ metadata: { name: 'n1', namespace: 'ns1' },
+ },
+ },
+ {
+ entity: {
+ apiVersion: 'av1',
+ kind: 'k1',
+ metadata: { name: 'n1', namespace: 'ns2' },
+ },
+ },
+ ];
await expect(
- db.transaction(tx => db.addEntity(tx, entityRequest)),
- ).resolves.toBeDefined();
+ db.transaction(tx => db.addEntities(tx, req)),
+ ).resolves.toEqual([
+ {
+ entity: expect.objectContaining({
+ metadata: expect.objectContaining({
+ namespace: 'ns1',
+ uid: expect.any(String),
+ etag: expect.any(String),
+ generation: expect.any(Number),
+ }),
+ }),
+ },
+ {
+ entity: expect.objectContaining({
+ metadata: expect.objectContaining({
+ namespace: 'ns2',
+ uid: expect.any(String),
+ etag: expect.any(String),
+ generation: expect.any(Number),
+ }),
+ }),
+ },
+ ]);
});
});
@@ -191,30 +254,34 @@ describe('CommonDatabase', () => {
const result = await db.locationHistory(
'dd12620d-0436-422f-93bd-929aa0788123',
);
- expect(result).toEqual([
- {
- created_at: expect.anything(),
- entity_name: null,
- id: expect.anything(),
- location_id: 'dd12620d-0436-422f-93bd-929aa0788123',
- message: null,
- status: DatabaseLocationUpdateLogStatus.SUCCESS,
- },
- {
- created_at: expect.anything(),
- entity_name: null,
- id: expect.anything(),
- location_id: 'dd12620d-0436-422f-93bd-929aa0788123',
- message: 'Something went wrong',
- status: DatabaseLocationUpdateLogStatus.FAIL,
- },
- ]);
+ expect(result).toEqual(
+ expect.arrayContaining([
+ {
+ created_at: expect.anything(),
+ entity_name: null,
+ id: expect.anything(),
+ location_id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ message: null,
+ status: DatabaseLocationUpdateLogStatus.SUCCESS,
+ },
+ {
+ created_at: expect.anything(),
+ entity_name: null,
+ id: expect.anything(),
+ location_id: 'dd12620d-0436-422f-93bd-929aa0788123',
+ message: 'Something went wrong',
+ status: DatabaseLocationUpdateLogStatus.FAIL,
+ },
+ ]),
+ );
});
});
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
- const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
+ const [added] = await db.transaction(tx =>
+ db.addEntities(tx, [entityRequest]),
+ );
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
@@ -231,7 +298,9 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
- const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
+ const [added] = await db.transaction(tx =>
+ db.addEntities(tx, [entityRequest]),
+ );
added.entity.metadata.name! = 'new!';
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
@@ -240,7 +309,9 @@ describe('CommonDatabase', () => {
});
it('fails to update an entity if etag does not match', async () => {
- const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
+ const [added] = await db.transaction(tx =>
+ db.addEntities(tx, [entityRequest]),
+ );
await expect(
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, 'garbage'),
@@ -249,7 +320,9 @@ describe('CommonDatabase', () => {
});
it('fails to update an entity if generation does not match', async () => {
- const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
+ const [added] = await db.transaction(tx =>
+ db.addEntities(tx, [entityRequest]),
+ );
await expect(
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, undefined, 1e20),
@@ -272,8 +345,7 @@ describe('CommonDatabase', () => {
spec: { c: null },
};
await db.transaction(async tx => {
- await db.addEntity(tx, { entity: e1 });
- await db.addEntity(tx, { entity: e2 });
+ await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]);
});
const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
@@ -309,9 +381,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
- for (const entity of entities) {
- await db.addEntity(tx, { entity });
- }
+ await db.addEntities(
+ tx,
+ entities.map(entity => ({ entity })),
+ );
});
await expect(
@@ -347,9 +420,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
- for (const entity of entities) {
- await db.addEntity(tx, { entity });
- }
+ await db.addEntities(
+ tx,
+ entities.map(entity => ({ entity })),
+ );
});
const rows = await db.transaction(async tx =>
@@ -396,9 +470,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
- for (const entity of entities) {
- await db.addEntity(tx, { entity });
- }
+ await db.addEntities(
+ tx,
+ entities.map(entity => ({ entity })),
+ );
});
const rows = await db.transaction(async tx =>
@@ -444,9 +519,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
- for (const entity of entities) {
- await db.addEntity(tx, { entity });
- }
+ await db.addEntities(
+ tx,
+ entities.map(entity => ({ entity })),
+ );
});
const e1 = await db.transaction(async tx =>
diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts
index 5327384593..49b36e0933 100644
--- a/plugins/catalog-backend/src/database/CommonDatabase.ts
+++ b/plugins/catalog-backend/src/database/CommonDatabase.ts
@@ -21,7 +21,6 @@ import {
} from '@backstage/backend-common';
import {
Entity,
- EntityMeta,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
@@ -46,6 +45,12 @@ import type {
EntityFilters,
} from './types';
+// The number of items that are sent per batch to the database layer, when
+// doing .batchInsert calls to knex. This needs to be low enough to not cause
+// errors in the underlying engine due to exceeding query limits, but large
+// enough to get the speed benefits.
+const BATCH_SIZE = 50;
+
/**
* The core database implementation.
*/
@@ -101,6 +106,52 @@ export class CommonDatabase implements Database {
return { locationId: request.locationId, entity: newEntity };
}
+ async addEntities(
+ txOpaque: unknown,
+ request: DbEntityRequest[],
+ ): Promise {
+ const tx = txOpaque as Knex.Transaction;
+
+ const result: DbEntityResponse[] = [];
+ const entityRows: DbEntitiesRow[] = [];
+ const searchRows: DbEntitiesSearchRow[] = [];
+
+ for (const { entity, locationId } of request) {
+ if (entity.metadata.uid !== undefined) {
+ throw new InputError('May not specify uid for new entities');
+ } else if (entity.metadata.etag !== undefined) {
+ throw new InputError('May not specify etag for new entities');
+ } else if (entity.metadata.generation !== undefined) {
+ throw new InputError('May not specify generation for new entities');
+ }
+
+ const newEntity = {
+ ...entity,
+ metadata: {
+ ...entity.metadata,
+ uid: generateEntityUid(),
+ etag: generateEntityEtag(),
+ generation: 1,
+ },
+ };
+
+ result.push({ entity: newEntity, locationId });
+ entityRows.push(this.toEntityRow(locationId, newEntity));
+ searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity));
+ }
+
+ await tx.batchInsert('entities', entityRows, BATCH_SIZE);
+ await tx('entities_search')
+ .whereIn(
+ 'entity_id',
+ entityRows.map(r => r.id),
+ )
+ .del();
+ await tx.batchInsert('entities_search', searchRows, BATCH_SIZE);
+
+ return result;
+ }
+
async updateEntity(
txOpaque: unknown,
request: DbEntityRequest,
@@ -166,10 +217,10 @@ export class CommonDatabase implements Database {
): Promise {
const tx = txOpaque as Knex.Transaction;
- let builder = tx('entities');
- for (const [indexU, filter] of (filters ?? []).entries()) {
- const index = Number(indexU);
- const key = filter.key.toLowerCase().replace(/\*/g, '%');
+ let entitiesQuery = tx('entities');
+
+ for (const filter of filters || []) {
+ const key = filter.key.toLowerCase().replace(/[*]/g, '%');
const keyOp = filter.key.includes('*') ? 'like' : '=';
let matchNulls = false;
@@ -180,38 +231,54 @@ export class CommonDatabase implements Database {
if (!value) {
matchNulls = true;
} else if (value.includes('*')) {
- matchLike.push(value.toLowerCase().replace(/\*/g, '%'));
+ matchLike.push(value.toLowerCase().replace(/[*]/g, '%'));
} else {
matchIn.push(value.toLowerCase());
}
}
- builder = builder
- .leftOuterJoin(`entities_search as t${index}`, function joins() {
- this.on('entities.id', '=', `t${index}.entity_id`);
- this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key]));
- })
- .where(function rules() {
- if (matchIn.length) {
- this.orWhereIn(`t${index}.value`, matchIn);
- }
- if (matchLike.length) {
- for (const x of matchLike) {
- this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x]));
+ // NOTE(freben): This used to be a set of OUTER JOIN, which may seem to
+ // make a lot of sense. However, it had abysmal performance on sqlite
+ // when datasets grew large, so we're using IN instead.
+ const matchQuery = tx('entities_search')
+ .select('entity_id')
+ .where(function keyFilter() {
+ this.andWhere('key', keyOp, key);
+ this.andWhere(function valueFilter() {
+ if (matchIn.length === 1) {
+ this.orWhere({ value: matchIn[0] });
+ } else if (matchIn.length > 1) {
+ this.orWhereIn('value', matchIn);
}
- }
- if (matchNulls) {
- this.orWhereNull(`t${index}.value`);
- }
+ if (matchLike.length) {
+ for (const x of matchLike) {
+ this.orWhere('value', 'like', tx.raw('?', [x]));
+ }
+ }
+ if (matchNulls) {
+ // Match explicit nulls, and then handle absence separately below
+ this.orWhereNull('value');
+ }
+ });
});
+
+ // Handle absence as nulls as well
+ entitiesQuery = entitiesQuery.andWhere(function match() {
+ this.whereIn('id', matchQuery);
+ if (matchNulls) {
+ this.orWhereNotIn(
+ 'id',
+ tx('entities_search')
+ .select('entity_id')
+ .where('key', keyOp, key),
+ );
+ }
+ });
}
- const rows = await builder
+ const rows = await entitiesQuery
.select('entities.*')
- .orderBy('kind', 'asc')
- .orderBy('namespace', 'asc')
- .orderBy('name', 'asc')
- .groupBy('id');
+ .orderBy('full_name', 'asc');
return rows.map(row => this.toEntityResponse(row));
}
@@ -223,12 +290,9 @@ export class CommonDatabase implements Database {
const tx = txOpaque as Knex.Transaction;
const rows = await tx('entities')
- .whereRaw(
- tx.raw(
- 'LOWER(kind) = LOWER(?) AND LOWER(namespace) = LOWER(?) AND LOWER(name) = LOWER(?)',
- [name.kind, name.namespace, name.name],
- ),
- )
+ .where({
+ full_name: `${name.kind}:${name.namespace}/${name.name}`.toLowerCase(),
+ })
.select();
if (rows.length !== 1) {
@@ -255,7 +319,7 @@ export class CommonDatabase implements Database {
return this.toEntityResponse(rows[0]);
}
- async removeEntity(txOpaque: unknown, uid: string): Promise {
+ async removeEntityByUid(txOpaque: unknown, uid: string): Promise {
const tx = txOpaque as Knex.Transaction;
const result = await tx('entities').where({ id: uid }).del();
@@ -389,39 +453,26 @@ export class CommonDatabase implements Database {
).toLowerCase();
const lowerName = entity.metadata.name.toLowerCase();
+ const data = {
+ ...entity,
+ metadata: lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS),
+ };
+
return {
id: entity.metadata.uid!,
location_id: locationId || null,
etag: entity.metadata.etag!,
generation: entity.metadata.generation!,
full_name: `${lowerKind}:${lowerNamespace}/${lowerName}`,
- api_version: entity.apiVersion,
- kind: entity.kind,
- name: entity.metadata.name,
- namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
- metadata: JSON.stringify(
- lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS),
- ),
- spec: entity.spec ? JSON.stringify(entity.spec) : null,
+ data: JSON.stringify(data),
};
}
private toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
- const entity: Entity = {
- apiVersion: row.api_version,
- kind: row.kind,
- metadata: {
- ...(JSON.parse(row.metadata) as EntityMeta),
- uid: row.id,
- etag: row.etag,
- generation: Number(row.generation), // cast because of sqlite
- },
- };
-
- if (row.spec) {
- const spec = JSON.parse(row.spec);
- entity.spec = spec;
- }
+ const entity = JSON.parse(row.data) as Entity;
+ entity.metadata.uid = row.id;
+ entity.metadata.etag = row.etag;
+ entity.metadata.generation = Number(row.generation); // cast due to sqlite
return {
locationId: row.location_id || undefined,
diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts
index afff254097..eb307c9478 100644
--- a/plugins/catalog-backend/src/database/DatabaseManager.ts
+++ b/plugins/catalog-backend/src/database/DatabaseManager.ts
@@ -19,6 +19,7 @@ import Knex from 'knex';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
+import { v4 as uuidv4 } from 'uuid';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-catalog-backend',
@@ -60,17 +61,40 @@ export class DatabaseManager {
}
public static async createTestDatabase(): Promise {
- const knex = Knex({
+ const config: Knex.Config = {
+ /*
+ client: 'pg',
+ connection: {
+ host: 'localhost',
+ user: 'postgres',
+ password: 'postgres',
+ },
+ */
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
- });
+ };
+
+ let knex = Knex(config);
+ if (typeof config.connection !== 'string') {
+ const tempDbName = `d${uuidv4().replace(/-/g, '')}`;
+ await knex.raw(`CREATE DATABASE ${tempDbName};`);
+ knex = Knex({
+ ...config,
+ connection: {
+ ...config.connection,
+ database: tempDbName,
+ },
+ });
+ }
+
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: migrationsDir,
});
+
const { logger } = defaultOptions;
return new CommonDatabase(knex, logger);
}
diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts
index c0f5777743..20cc123c0a 100644
--- a/plugins/catalog-backend/src/database/types.ts
+++ b/plugins/catalog-backend/src/database/types.ts
@@ -19,15 +19,10 @@ import type { Entity, EntityName, Location } from '@backstage/catalog-model';
export type DbEntitiesRow = {
id: string;
location_id: string | null;
- api_version: string;
- kind: string;
- name: string | null;
- namespace: string | null;
etag: string;
generation: number;
full_name: string;
- metadata: string;
- spec: string | null;
+ data: string;
};
export type DbEntityRequest = {
@@ -94,13 +89,15 @@ export type Database = {
transaction(fn: (tx: unknown) => Promise): Promise;
/**
- * Adds a new entity to the catalog.
+ * Adds a set of new entities to the catalog.
*
* @param tx An ongoing transaction
- * @param request The entity being added
- * @returns The added entity, with uid, etag and generation set
+ * @param request The entities being added
*/
- addEntity(tx: unknown, request: DbEntityRequest): Promise;
+ addEntities(
+ tx: unknown,
+ request: DbEntityRequest[],
+ ): Promise;
/**
* Updates an existing entity in the catalog.
@@ -137,7 +134,7 @@ export type Database = {
entityByUid(tx: unknown, uid: string): Promise;
- removeEntity(tx: unknown, uid: string): Promise;
+ removeEntityByUid(tx: unknown, uid: string): Promise;
addLocation(location: Location): Promise;
diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts
index 32fcc27909..15d9168732 100644
--- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts
+++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts
@@ -39,6 +39,7 @@ describe('HigherOrderOperations', () => {
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
+ addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
};
locationsCatalog = {
@@ -191,8 +192,8 @@ describe('HigherOrderOperations', () => {
entities: [{ entity: desc, location }],
errors: [],
});
- entitiesCatalog.entityByName.mockResolvedValue(undefined);
- entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc);
+ entitiesCatalog.entities.mockResolvedValue([]);
+ entitiesCatalog.addEntities.mockResolvedValue(undefined);
await expect(
higherOrderOperation.refreshAllLocations(),
@@ -204,18 +205,19 @@ describe('HigherOrderOperations', () => {
type: 'some',
target: 'thing',
});
- expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
- expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, {
- kind: 'Component',
- namespace: ENTITY_DEFAULT_NAMESPACE,
- name: 'c1',
- });
- expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
- expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
+ expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.entities).toHaveBeenNthCalledWith(
1,
- expect.objectContaining({
- metadata: expect.objectContaining({ name: 'c1' }),
- }),
+ expect.arrayContaining([
+ { key: 'kind', values: ['Component'] },
+ { key: 'metadata.namespace', values: [ENTITY_DEFAULT_NAMESPACE] },
+ { key: 'metadata.name', values: ['c1'] },
+ ]),
+ );
+ expect(entitiesCatalog.addEntities).toHaveBeenCalledTimes(1);
+ expect(entitiesCatalog.addEntities).toHaveBeenNthCalledWith(
+ 1,
+ [expect.objectContaining({ metadata: { name: 'c1' } })],
'123',
);
});
@@ -245,8 +247,8 @@ describe('HigherOrderOperations', () => {
entities: [{ entity: desc, location }],
errors: [],
});
- entitiesCatalog.entityByName.mockResolvedValue(undefined);
- entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc);
+ entitiesCatalog.entities.mockResolvedValue([]);
+ entitiesCatalog.addEntities.mockResolvedValue(undefined);
await expect(
higherOrderOperation.refreshAllLocations(),
diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts
index 571602b966..737166436c 100644
--- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts
+++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { InputError } from '@backstage/backend-common';
+import { ConflictError, InputError } from '@backstage/backend-common';
import {
Entity,
entityHasChanges,
@@ -23,15 +23,37 @@ import {
LocationSpec,
serializeEntityRef,
} from '@backstage/catalog-model';
+import { chunk, groupBy } from 'lodash';
+import limiterFactory from 'p-limit';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
+import { durationText } from '../util/timing';
import {
AddLocationResult,
HigherOrderOperation,
LocationReader,
} from './types';
+type BatchContext = {
+ kind: string;
+ namespace: string;
+ location: Location;
+};
+
+// Some locations return tens or hundreds of thousands of entities. To make
+// those payloads more manageable, we break work apart in batches of this
+// many entities and write them to storage per batch.
+const BATCH_SIZE = 100;
+
+// When writing large batches, there's an increasing chance of contention in
+// the form of conflicts where we compete with other writes. Each batch gets
+// this many attempts at being written before giving up.
+const BATCH_ATTEMPTS = 3;
+
+// The number of batches that may be ongoing at the same time.
+const BATCH_CONCURRENCY = 3;
+
/**
* Placeholder for operations that span several catalogs and/or stretches out
* in time.
@@ -88,7 +110,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
if (readerOutput.errors.length) {
const item = readerOutput.errors[0];
throw new InputError(
- `Failed to read location ${item.location.type} ${item.location.target}, ${item.error}`,
+ `Failed to read location ${item.location.type}:${item.location.target}, ${item.error}`,
);
}
@@ -121,85 +143,209 @@ export class HigherOrderOperations implements HigherOrderOperation {
* without changes.
*/
async refreshAllLocations(): Promise {
- const startTimestamp = new Date().valueOf();
+ const startTimestamp = process.hrtime();
this.logger.info('Beginning locations refresh');
const locations = await this.locationsCatalog.locations();
this.logger.info(`Visiting ${locations.length} locations`);
for (const { data: location } of locations) {
- this.logger.debug(
- `Refreshing location id="${location.id}" type="${location.type}" target="${location.target}"`,
+ this.logger.info(
+ `Refreshing location ${location.type}:${location.target}`,
);
try {
await this.refreshSingleLocation(location);
await this.locationsCatalog.logUpdateSuccess(location.id, undefined);
} catch (e) {
- this.logger.debug(
- `Failed to refresh location id="${location.id}" type="${location.type}" target="${location.target}", ${e}`,
+ this.logger.warn(
+ `Failed to refresh location ${location.type}:${location.target}, ${e}`,
);
await this.locationsCatalog.logUpdateFailure(location.id, e);
}
}
- const endTimestamp = new Date().valueOf();
- const duration = ((endTimestamp - startTimestamp) / 1000).toFixed(1);
- this.logger.debug(`Completed locations refresh in ${duration} seconds`);
+ this.logger.info(
+ `Completed locations refresh in ${durationText(startTimestamp)}`,
+ );
}
// Performs a full refresh of a single location
private async refreshSingleLocation(location: Location) {
+ let startTimestamp = process.hrtime();
+
const readerOutput = await this.locationReader.read({
type: location.type,
target: location.target,
});
for (const item of readerOutput.errors) {
- this.logger.debug(
- `Failed item in location type="${item.location.type}" target="${item.location.target}", ${item.error}`,
+ this.logger.warn(
+ `Failed item in location ${item.location.type}:${item.location.target}, ${item.error}`,
);
}
this.logger.info(
- `Read ${readerOutput.entities.length} entities from location ${location.type} ${location.target}`,
+ `Read ${readerOutput.entities.length} entities from location ${
+ location.type
+ }:${location.target} in ${durationText(startTimestamp)}`,
);
- const startTimestamp = process.hrtime();
- for (const item of readerOutput.entities) {
- const { entity } = item;
+ startTimestamp = process.hrtime();
- try {
- const previous = await this.entitiesCatalog.entityByName(
- getEntityName(entity),
- );
+ await this.batchAddOrUpdateEntities(
+ readerOutput.entities.map(e => e.entity),
+ location,
+ );
- if (!previous) {
- await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
- } else if (entityHasChanges(previous, entity)) {
- await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
- }
+ this.logger.info(
+ `Wrote ${readerOutput.entities.length} entities from location ${
+ location.type
+ }:${location.target} in ${durationText(startTimestamp)}`,
+ );
+ }
- await this.locationsCatalog.logUpdateSuccess(
- location.id,
- entity.metadata.name,
- );
- } catch (error) {
- this.logger.info(
- `Failed refresh of entity ${serializeEntityRef(entity)}, ${error}`,
- );
+ /**
+ * Writes a number of entities efficiently to storage.
+ *
+ * @param entities Some entities
+ * @param location The location that they all belong to
+ */
+ async batchAddOrUpdateEntities(entities: Entity[], location: Location) {
+ // Group the entities by unique kind+namespace combinations
+ const entitiesByKindAndNamespace = groupBy(entities, entity => {
+ const name = getEntityName(entity);
+ return `${name.kind}:${name.namespace}`.toLowerCase();
+ });
- await this.locationsCatalog.logUpdateFailure(
- location.id,
- error,
- entity.metadata.name,
+ const limiter = limiterFactory(BATCH_CONCURRENCY);
+ const tasks: Promise[] = [];
+
+ for (const groupEntities of Object.values(entitiesByKindAndNamespace)) {
+ const { kind, namespace } = getEntityName(groupEntities[0]);
+
+ // Go through the new entities in reasonable chunk sizes (sometimes,
+ // sources produce tens of thousands of entities, and those are too large
+ // batch sizes to reasonably send to the database)
+ for (const batch of chunk(groupEntities, BATCH_SIZE)) {
+ tasks.push(
+ limiter(async () => {
+ const first = serializeEntityRef(batch[0]);
+ const last = serializeEntityRef(batch[batch.length - 1]);
+ this.logger.debug(
+ `Considering batch ${first}-${last} (${batch.length} entries)`,
+ );
+
+ // Retry the batch write a few times to deal with contention
+ const context = { kind, namespace, location };
+ for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) {
+ try {
+ const { toAdd, toUpdate } = await this.analyzeBatch(
+ batch,
+ context,
+ );
+ if (toAdd.length) await this.batchAdd(toAdd, context);
+ if (toUpdate.length) await this.batchUpdate(toUpdate, context);
+ break;
+ } catch (e) {
+ if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) {
+ this.logger.warn(
+ `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`,
+ );
+ } else {
+ throw e;
+ }
+ }
+ }
+ }),
);
}
}
- const delta = process.hrtime(startTimestamp);
- const durationMs = ((delta[0] * 1e9 + delta[1]) / 1e6).toFixed(1);
- this.logger.info(
- `Wrote ${readerOutput.entities.length} entities from location ${location.type} ${location.target} in ${durationMs} seconds`,
+ await Promise.all(tasks);
+ }
+
+ // Given a batch of entities that were just read from a location, take them
+ // into consideration by comparing against the existing catalog entities and
+ // produce the list of entities to be added, and the list of entities to be
+ // updated
+ private async analyzeBatch(
+ newEntities: Entity[],
+ { kind, namespace }: BatchContext,
+ ): Promise<{
+ toAdd: Entity[];
+ toUpdate: Entity[];
+ }> {
+ const markTimestamp = process.hrtime();
+
+ const names = newEntities.map(e => e.metadata.name);
+ const oldEntities = await this.entitiesCatalog.entities([
+ { key: 'kind', values: [kind] },
+ { key: 'metadata.namespace', values: [namespace] },
+ { key: 'metadata.name', values: names },
+ ]);
+
+ const oldEntitiesByName = new Map(
+ oldEntities.map(e => [e.metadata.name, e]),
+ );
+
+ const toAdd: Entity[] = [];
+ const toUpdate: Entity[] = [];
+
+ for (const newEntity of newEntities) {
+ const oldEntity = oldEntitiesByName.get(newEntity.metadata.name);
+ if (!oldEntity) {
+ toAdd.push(newEntity);
+ } else if (entityHasChanges(oldEntity, newEntity)) {
+ toUpdate.push(newEntity);
+ }
+ }
+
+ this.logger.debug(
+ `Found ${toAdd.length} entities to add, ${
+ toUpdate.length
+ } entities to update in ${durationText(markTimestamp)}`,
+ );
+
+ return { toAdd, toUpdate };
+ }
+
+ // Efficiently adds the given entities to storage, under the assumption that
+ // they do not conflict with any existing entities
+ private async batchAdd(entities: Entity[], { location }: BatchContext) {
+ const markTimestamp = process.hrtime();
+
+ await this.entitiesCatalog.addEntities(entities, location.id);
+
+ // TODO(freben): Still not batched
+ for (const entity of entities) {
+ await this.locationsCatalog.logUpdateSuccess(
+ location.id,
+ entity.metadata.name,
+ );
+ }
+
+ this.logger.debug(
+ `Added ${entities.length} entities in ${durationText(markTimestamp)}`,
+ );
+ }
+
+ // Efficiently updates the given entities into storage, under the assumption
+ // that there already exist entities with the same names
+ private async batchUpdate(entities: Entity[], { location }: BatchContext) {
+ const markTimestamp = process.hrtime();
+
+ // TODO(freben): Still not batched
+ for (const entity of entities) {
+ await this.entitiesCatalog.addOrUpdateEntity(entity);
+
+ await this.locationsCatalog.logUpdateSuccess(
+ location.id,
+ entity.metadata.name,
+ );
+ }
+
+ this.logger.debug(
+ `Updated ${entities.length} entities in ${durationText(markTimestamp)}`,
);
}
}
diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts
index fcc608eb51..87c75e21e5 100644
--- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts
+++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts
@@ -26,7 +26,6 @@ import { Config, ConfigReader } from '@backstage/config';
import { Logger } from 'winston';
import { CatalogRulesEnforcer } from './CatalogRules';
import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEntityProcessor';
-import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor';
import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor';
import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor';
import { CodeOwnersProcessor } from './processors/CodeOwnersProcessor';
@@ -125,9 +124,8 @@ export class LocationReaders implements LocationReader {
LdapOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor(options),
new YamlProcessor(),
- PlaceholderProcessor.default(),
- new CodeOwnersProcessor(),
- new ApiDefinitionAtLocationProcessor(),
+ PlaceholderProcessor.default({ reader: options.reader }),
+ new CodeOwnersProcessor({ reader: options.reader }),
new EntityPolicyProcessor(entityPolicy),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
@@ -253,12 +251,7 @@ export class LocationReaders implements LocationReader {
for (const processor of this.processors) {
if (processor.processEntity) {
try {
- current = await processor.processEntity(
- current,
- item.location,
- emit,
- this.readLocation.bind(this),
- );
+ current = await processor.processEntity(current, item.location, emit);
} catch (e) {
// Construct the name carefully, if we got validation errors we do
// not want to crash here due to missing metadata or so
@@ -296,40 +289,4 @@ export class LocationReaders implements LocationReader {
}
}
}
-
- private async readLocation(location: LocationSpec): Promise {
- let data: Buffer | undefined = undefined;
- let error: Error | undefined = undefined;
-
- await this.handleLocation(
- {
- type: 'location',
- location,
- optional: false,
- },
- output => {
- if (output.type === 'error' && !error) {
- error = output.error;
- } else if (output.type === 'data') {
- if (data) {
- if (!error) {
- error = new Error(
- 'More than one piece of data loaded unexpectedly',
- );
- }
- } else {
- data = output.data;
- }
- }
- },
- );
-
- if (error) {
- throw error;
- } else if (!data) {
- throw new Error('No data loaded');
- }
-
- return data;
- }
}
diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts
deleted file mode 100644
index 6ae6562339..0000000000
--- a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model';
-/*
- * 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 { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor';
-
-describe('ApiDefinitionAtLocationProcessor', () => {
- let processor: ApiDefinitionAtLocationProcessor;
- let entity: Entity;
- let location: LocationSpec;
-
- beforeEach(() => {
- processor = new ApiDefinitionAtLocationProcessor();
- entity = {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'API',
- metadata: {
- name: 'test',
- },
- spec: {
- lifecycle: 'production',
- owner: 'info@example.com',
- type: 'openapi',
- definition: 'Hello',
- },
- };
- location = {
- type: 'url',
- target: `http://example.com/api.yaml`,
- };
- });
-
- it('should skip entities without annotation', async () => {
- const read = jest.fn().mockRejectedValue(new Error('boo'));
-
- const generated = (await processor.processEntity(
- entity,
- location,
- () => {},
- read,
- )) as ApiEntity;
-
- expect(generated.spec.definition).toBe('Hello');
- });
-
- it('should load from location', async () => {
- entity.metadata.annotations = {
- 'backstage.io/definition-at-location':
- 'url:http://example.com/openapi.yaml',
- };
-
- const read = jest.fn().mockResolvedValue(Buffer.from('Hello'));
-
- const generated = (await processor.processEntity(
- entity,
- location,
- () => {},
- read,
- )) as ApiEntity;
-
- expect(generated.spec.definition).toBe('Hello');
- expect(read.mock.calls[0][0]).toStrictEqual({
- type: 'url',
- target: 'http://example.com/openapi.yaml',
- });
- });
-
- it('should throw errors while loading', async () => {
- entity.metadata.annotations = {
- 'backstage.io/definition-at-location': 'missing',
- };
-
- const read = jest
- .fn()
- .mockRejectedValue(new Error('Failed to load location'));
-
- await expect(
- processor.processEntity(entity, location, () => {}, read),
- ).rejects.toThrow('Failed to load location');
- });
-});
diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts
deleted file mode 100644
index e64d112e9c..0000000000
--- a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model';
-import {
- LocationProcessor,
- LocationProcessorEmit,
- LocationProcessorRead,
-} from './types';
-
-const DEFINITION_AT_LOCATION_ANNOTATION = 'backstage.io/definition-at-location';
-
-export class ApiDefinitionAtLocationProcessor implements LocationProcessor {
- async processEntity(
- entity: Entity,
- _location: LocationSpec,
- _emit: LocationProcessorEmit,
- read: LocationProcessorRead,
- ): Promise {
- if (
- entity.kind !== 'API' ||
- !entity.metadata.annotations ||
- !entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION]
- ) {
- return entity;
- }
-
- const reference =
- entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION];
- const { type, target } = extractReference(reference);
- const data = await read({ type, target });
- const definition = data.toString();
- const apiEntity = entity as ApiEntity;
- apiEntity.spec.definition = definition;
-
- return entity;
- }
-}
-
-function extractReference(reference: string): { type: string; target: string } {
- const delimiterIndex = reference.indexOf(':');
- const type = reference.slice(0, delimiterIndex);
- const target = reference.slice(delimiterIndex + 1);
-
- return { type, target };
-}
diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts
index 29f94dd0d7..c1c83b7c6c 100644
--- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts
@@ -17,7 +17,7 @@
import { LocationSpec } from '@backstage/catalog-model';
import { CodeOwnersEntry } from 'codeowners-utils';
import {
- buildCodeOwnerLocation,
+ buildCodeOwnerUrl,
buildUrl,
CodeOwnersProcessor,
findPrimaryCodeOwner,
@@ -28,18 +28,18 @@ import {
} from './CodeOwnersProcessor';
describe(CodeOwnersProcessor, () => {
+ const mockUrl = ({ basePath = '' } = {}): string =>
+ `https://github.com/spotify/backstage/blob/master/${basePath}catalog-info.yaml`;
const mockLocation = ({
basePath = '',
type = 'github',
} = {}): LocationSpec => ({
type,
- target: `https://github.com/spotify/backstage/blob/master/${basePath}catalog-info.yaml`,
+ target: mockUrl({ basePath }),
});
- const mockReadLocation = (basePath = '') => ({
- type: 'github',
- target: `https://github.com/spotify/backstage/blob/master/${basePath}CODEOWNERS`,
- });
+ const mockReadUrl = (basePath = '') =>
+ `https://github.com/spotify/backstage/blob/master/${basePath}CODEOWNERS`;
const mockGitUri = (codeOwnersPath: string = '') => {
return {
@@ -99,27 +99,20 @@ describe(CodeOwnersProcessor, () => {
});
});
- describe(buildCodeOwnerLocation, () => {
- it('should builds a location spec to the codeowners', () => {
- expect(
- buildCodeOwnerLocation(mockLocation(), '/docs/CODEOWNERS'),
- ).toEqual({
- type: 'github',
- target:
- 'https://github.com/spotify/backstage/blob/master/docs/CODEOWNERS',
- });
+ describe(buildCodeOwnerUrl, () => {
+ it('should build a location spec to the codeowners', () => {
+ expect(buildCodeOwnerUrl(mockUrl(), '/docs/CODEOWNERS')).toEqual(
+ 'https://github.com/spotify/backstage/blob/master/docs/CODEOWNERS',
+ );
});
it('should handle nested paths from original location spec', () => {
expect(
- buildCodeOwnerLocation(
- mockLocation({ basePath: 'packages/foo/' }),
+ buildCodeOwnerUrl(
+ mockUrl({ basePath: 'packages/foo/' }),
'/CODEOWNERS',
),
- ).toEqual({
- type: 'github',
- target: 'https://github.com/spotify/backstage/blob/master/CODEOWNERS',
- });
+ ).toEqual('https://github.com/spotify/backstage/blob/master/CODEOWNERS');
});
});
@@ -158,15 +151,17 @@ describe(CodeOwnersProcessor, () => {
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: ownersText }));
- const result = await findRawCodeOwners(mockLocation(), read);
+ const reader = { read };
+ const result = await findRawCodeOwners(mockLocation(), reader);
expect(result).toEqual(ownersText);
});
it('should raise error when no codeowner', async () => {
const read = jest.fn().mockRejectedValue(mockReadResult());
+ const reader = { read };
await expect(
- findRawCodeOwners(mockLocation(), read),
+ findRawCodeOwners(mockLocation(), reader),
).rejects.toBeInstanceOf(Error);
});
@@ -177,13 +172,14 @@ describe(CodeOwnersProcessor, () => {
.mockImplementationOnce(() => mockReadResult({ error: 'foo' }))
.mockImplementationOnce(() => mockReadResult({ error: 'bar' }))
.mockResolvedValue(mockReadResult({ data: ownersText }));
+ const reader = { read };
- const result = await findRawCodeOwners(mockLocation(), read);
+ const result = await findRawCodeOwners(mockLocation(), reader);
expect(read.mock.calls.length).toBe(3);
- expect(read.mock.calls[0]).toEqual([mockReadLocation('.github/')]);
- expect(read.mock.calls[1]).toEqual([mockReadLocation('')]);
- expect(read.mock.calls[2]).toEqual([mockReadLocation('docs/')]);
+ expect(read.mock.calls[0]).toEqual([mockReadUrl('.github/')]);
+ expect(read.mock.calls[1]).toEqual([mockReadUrl('')]);
+ expect(read.mock.calls[2]).toEqual([mockReadUrl('docs/')]);
expect(result).toEqual(ownersText);
});
});
@@ -193,7 +189,9 @@ describe(CodeOwnersProcessor, () => {
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
- const owner = await resolveCodeOwner(mockLocation(), read);
+ const reader = { read };
+
+ const owner = await resolveCodeOwner(mockLocation(), reader);
expect(owner).toBe('backstage-core');
});
@@ -201,9 +199,10 @@ describe(CodeOwnersProcessor, () => {
const read = jest
.fn()
.mockImplementation(() => mockReadResult({ error: 'error: foo' }));
+ const reader = { read };
await expect(
- resolveCodeOwner(mockLocation(), read),
+ resolveCodeOwner(mockLocation(), reader),
).rejects.toBeInstanceOf(Error);
});
});
@@ -211,10 +210,11 @@ describe(CodeOwnersProcessor, () => {
describe(CodeOwnersProcessor, () => {
const setupTest = ({ kind = 'Component', spec = {} } = {}) => {
const entity = { kind, spec };
- const processor = new CodeOwnersProcessor();
const read = jest
.fn()
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
+ const reader = { read };
+ const processor = new CodeOwnersProcessor({ reader });
return { entity, processor, read };
};
@@ -227,8 +227,6 @@ describe(CodeOwnersProcessor, () => {
const result = await processor.processEntity(
entity as any,
mockLocation(),
- null as any,
- null as any,
);
expect(result).toEqual(entity);
@@ -240,8 +238,6 @@ describe(CodeOwnersProcessor, () => {
const result = await processor.processEntity(
entity as any,
mockLocation({ type: 'url' }),
- null as any,
- null as any,
);
expect(result).toEqual(entity);
@@ -253,21 +249,17 @@ describe(CodeOwnersProcessor, () => {
const result = await processor.processEntity(
entity as any,
mockLocation(),
- null as any,
- null as any,
);
expect(result).toEqual(entity);
});
it('should set owner from codeowner', async () => {
- const { entity, processor, read } = setupTest();
+ const { entity, processor } = setupTest();
const result = await processor.processEntity(
entity as any,
mockLocation(),
- null as any,
- read,
);
expect(result).toEqual({
diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts
index c875bdc799..e624af91ee 100644
--- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts
@@ -14,12 +14,9 @@
* limitations under the License.
*/
+import { UrlReader } from '@backstage/backend-common';
import { Entity, LocationSpec } from '@backstage/catalog-model';
-import {
- LocationProcessor,
- LocationProcessorEmit,
- LocationProcessorRead,
-} from './types';
+import { LocationProcessor } from './types';
import * as codeowners from 'codeowners-utils';
import { CodeOwnersEntry } from 'codeowners-utils';
import parseGitUri from 'git-url-parse';
@@ -37,13 +34,14 @@ const ALLOWED_LOCATION_TYPES = [
'gitlab/api',
];
+type Options = {
+ reader: UrlReader;
+};
+
export class CodeOwnersProcessor implements LocationProcessor {
- async processEntity(
- entity: Entity,
- location: LocationSpec,
- _emit: LocationProcessorEmit,
- read: LocationProcessorRead,
- ): Promise {
+ constructor(private readonly options: Options) {}
+
+ async processEntity(entity: Entity, location: LocationSpec): Promise {
// Only continue if the owner is not set
if (
!entity ||
@@ -54,7 +52,7 @@ export class CodeOwnersProcessor implements LocationProcessor {
return entity;
}
- const owner = await resolveCodeOwner(location, read);
+ const owner = await resolveCodeOwner(location, this.options.reader);
return {
...entity,
@@ -65,9 +63,9 @@ export class CodeOwnersProcessor implements LocationProcessor {
export async function resolveCodeOwner(
location: LocationSpec,
- read: LocationProcessorRead,
+ reader: UrlReader,
): Promise {
- const ownersText = await findRawCodeOwners(location, read);
+ const ownersText = await findRawCodeOwners(location, reader);
if (!ownersText) {
throw Error(`Unable to find codeowners file for: ${location.target}`);
@@ -80,15 +78,14 @@ export async function resolveCodeOwner(
export async function findRawCodeOwners(
location: LocationSpec,
- read: LocationProcessorRead,
+ reader: UrlReader,
): Promise {
const readOwnerLocation = async (basePath: string): Promise => {
- const ownerLocation = buildCodeOwnerLocation(
- location,
+ const ownerUrl = buildCodeOwnerUrl(
+ location.target,
`${basePath}/CODEOWNERS`,
);
-
- const data = await read(ownerLocation);
+ const data = await reader.read(ownerUrl);
return data.toString();
};
@@ -101,6 +98,13 @@ export async function findRawCodeOwners(
]);
}
+export function buildCodeOwnerUrl(
+ basePath: string,
+ codeOwnersPath: string,
+): string {
+ return buildUrl({ ...parseGitUri(basePath), codeOwnersPath });
+}
+
export function parseCodeOwners(ownersText: string) {
return codeowners.parse(ownersText);
}
@@ -128,15 +132,6 @@ export function normalizeCodeOwner(owner: string) {
return owner;
}
-export function buildCodeOwnerLocation(
- location: LocationSpec,
- codeOwnersPath: string,
-): LocationSpec {
- const { type, target } = location;
-
- return { type, target: buildUrl({ ...parseGitUri(target), codeOwnersPath }) };
-}
-
export function buildUrl({
protocol = 'https',
source = 'github.com',
diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts
index 6893cae0e7..c196458e2a 100644
--- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts
@@ -13,20 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
+import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import {
jsonPlaceholderResolver,
PlaceholderProcessor,
PlaceholderResolver,
ResolverParams,
+ ResolverRead,
yamlPlaceholderResolver,
} from './PlaceholderProcessor';
-import { LocationProcessorEmit, LocationProcessorRead } from './types';
describe('PlaceholderProcessor', () => {
- const emit: LocationProcessorEmit = jest.fn();
- const read: jest.MockedFunction = jest.fn();
+ const read: jest.MockedFunction = jest.fn();
+ const reader: UrlReader = { read };
beforeEach(() => {
jest.resetAllMocks();
@@ -39,10 +39,13 @@ describe('PlaceholderProcessor', () => {
metadata: { name: 'n' },
};
const processor = new PlaceholderProcessor({
- foo: async () => 'replaced',
+ resolvers: {
+ foo: async () => 'replaced',
+ },
+ reader,
});
await expect(
- processor.processEntity(input, { type: 't', target: 'l' }, emit, read),
+ processor.processEntity(input, { type: 't', target: 'l' }),
).resolves.toBe(input);
});
@@ -51,7 +54,10 @@ describe('PlaceholderProcessor', () => {
value!.toString().toUpperCase(),
);
const processor = new PlaceholderProcessor({
- upper: upperResolver,
+ resolvers: {
+ upper: upperResolver,
+ },
+ reader,
});
await expect(
@@ -63,8 +69,6 @@ describe('PlaceholderProcessor', () => {
spec: { a: [{ b: { $upper: 'text' } }] },
},
{ type: 'fake', target: 'http://example.com' },
- emit,
- read,
),
).resolves.toEqual({
apiVersion: 'a',
@@ -73,20 +77,23 @@ describe('PlaceholderProcessor', () => {
spec: { a: [{ b: 'TEXT' }] },
});
- expect(emit).not.toBeCalled();
expect(read).not.toBeCalled();
- expect(upperResolver).toBeCalledWith({
- key: 'upper',
- value: 'text',
- location: { type: 'fake', target: 'http://example.com' },
- read,
- });
+ expect(upperResolver).toBeCalledWith(
+ expect.objectContaining({
+ key: 'upper',
+ value: 'text',
+ baseUrl: 'http://example.com',
+ }),
+ );
});
it('rejects multiple placeholders', async () => {
const processor = new PlaceholderProcessor({
- foo: jest.fn(),
- bar: jest.fn(),
+ resolvers: {
+ foo: jest.fn(),
+ bar: jest.fn(),
+ },
+ reader,
});
await expect(
@@ -97,20 +104,20 @@ describe('PlaceholderProcessor', () => {
metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } },
},
{ type: 'a', target: 'b' },
- emit,
- read,
),
).rejects.toThrow(
'Placeholders have to be on the form of a single $-prefixed key in an object',
);
- expect(emit).not.toBeCalled();
expect(read).not.toBeCalled();
});
it('rejects unknown placeholders', async () => {
const processor = new PlaceholderProcessor({
- bar: jest.fn(),
+ resolvers: {
+ bar: jest.fn(),
+ },
+ reader,
});
await expect(
@@ -121,18 +128,15 @@ describe('PlaceholderProcessor', () => {
metadata: { name: 'n', x: { $foo: 'a' } },
},
{ type: 'a', target: 'b' },
- emit,
- read,
),
).rejects.toThrow('Encountered unknown placeholder $foo');
- expect(emit).not.toBeCalled();
expect(read).not.toBeCalled();
});
it('has builtin text support', async () => {
read.mockResolvedValue(Buffer.from('TEXT', 'utf-8'));
- const processor = PlaceholderProcessor.default();
+ const processor = PlaceholderProcessor.default({ reader });
await expect(
processor.processEntity(
@@ -146,8 +150,6 @@ describe('PlaceholderProcessor', () => {
type: 'github',
target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
},
- emit,
- read,
),
).resolves.toEqual({
apiVersion: 'a',
@@ -156,18 +158,16 @@ describe('PlaceholderProcessor', () => {
spec: { data: 'TEXT' },
});
- expect(emit).not.toBeCalled();
- expect(read).toBeCalledWith({
- type: 'github',
- target: 'https://github.com/spotify/backstage/a/file.txt',
- });
+ expect(read).toBeCalledWith(
+ 'https://github.com/spotify/backstage/a/file.txt',
+ );
});
it('has builtin json support', async () => {
read.mockResolvedValue(
Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'),
);
- const processor = PlaceholderProcessor.default();
+ const processor = PlaceholderProcessor.default({ reader });
await expect(
processor.processEntity(
@@ -181,8 +181,6 @@ describe('PlaceholderProcessor', () => {
type: 'github',
target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
},
- emit,
- read,
),
).resolves.toEqual({
apiVersion: 'a',
@@ -191,16 +189,14 @@ describe('PlaceholderProcessor', () => {
spec: { data: { a: ['b', 7] } },
});
- expect(emit).not.toBeCalled();
- expect(read).toBeCalledWith({
- type: 'github',
- target: 'https://github.com/spotify/backstage/a/b/file.json',
- });
+ expect(read).toBeCalledWith(
+ 'https://github.com/spotify/backstage/a/b/file.json',
+ );
});
it('has builtin yaml support', async () => {
read.mockResolvedValue(Buffer.from('foo:\n - bar: 7', 'utf-8'));
- const processor = PlaceholderProcessor.default();
+ const processor = PlaceholderProcessor.default({ reader });
await expect(
processor.processEntity(
@@ -214,8 +210,6 @@ describe('PlaceholderProcessor', () => {
type: 'github',
target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
},
- emit,
- read,
),
).resolves.toEqual({
apiVersion: 'a',
@@ -224,16 +218,14 @@ describe('PlaceholderProcessor', () => {
spec: { data: { foo: [{ bar: 7 }] } },
});
- expect(emit).not.toBeCalled();
- expect(read).toBeCalledWith({
- type: 'github',
- target: 'https://github.com/spotify/backstage/a/file.yaml',
- });
+ expect(read).toBeCalledWith(
+ 'https://github.com/spotify/backstage/a/file.yaml',
+ );
});
it('resolves absolute path for absolute location', async () => {
read.mockResolvedValue(Buffer.from('TEXT', 'utf-8'));
- const processor = PlaceholderProcessor.default();
+ const processor = PlaceholderProcessor.default({ reader });
await expect(
processor.processEntity(
@@ -251,8 +243,6 @@ describe('PlaceholderProcessor', () => {
type: 'github',
target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
},
- emit,
- read,
),
).resolves.toEqual({
apiVersion: 'a',
@@ -261,16 +251,14 @@ describe('PlaceholderProcessor', () => {
spec: { data: 'TEXT' },
});
- expect(emit).not.toBeCalled();
- expect(read).toBeCalledWith({
- type: 'github',
- target: 'https://github.com/spotify/backstage/catalog-info.yaml',
- });
+ expect(read).toBeCalledWith(
+ 'https://github.com/spotify/backstage/catalog-info.yaml',
+ );
});
it('resolves absolute path for relative file location', async () => {
read.mockResolvedValue(Buffer.from('TEXT', 'utf-8'));
- const processor = PlaceholderProcessor.default();
+ const processor = PlaceholderProcessor.default({ reader });
await expect(
processor.processEntity(
@@ -288,8 +276,6 @@ describe('PlaceholderProcessor', () => {
type: 'github',
target: './a/b/catalog-info.yaml',
},
- emit,
- read,
),
).resolves.toEqual({
apiVersion: 'a',
@@ -298,11 +284,9 @@ describe('PlaceholderProcessor', () => {
spec: { data: 'TEXT' },
});
- expect(emit).not.toBeCalled();
- expect(read).toBeCalledWith({
- type: 'github',
- target: 'https://github.com/spotify/backstage/catalog-info.yaml',
- });
+ expect(read).toBeCalledWith(
+ 'https://github.com/spotify/backstage/catalog-info.yaml',
+ );
});
it('not resolves relative file path for relative file location', async () => {
@@ -310,7 +294,7 @@ describe('PlaceholderProcessor', () => {
// traversel attacks. If we want to implement this, we need to have additional
// security measures in place!
read.mockResolvedValue(Buffer.from('TEXT', 'utf-8'));
- const processor = PlaceholderProcessor.default();
+ const processor = PlaceholderProcessor.default({ reader });
await expect(
processor.processEntity(
@@ -328,27 +312,21 @@ describe('PlaceholderProcessor', () => {
type: 'github',
target: './a/b/catalog-info.yaml',
},
- emit,
- read,
),
).rejects.toThrow(
'Placeholder $text could not form an URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml',
);
- expect(emit).not.toBeCalled();
expect(read).not.toBeCalled();
});
});
describe('yamlPlaceholderResolver', () => {
- const read: jest.MockedFunction = jest.fn();
+ const read: jest.MockedFunction = jest.fn();
const params: ResolverParams = {
key: 'a',
value: './file.yaml',
- location: {
- type: 'github',
- target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
- },
+ baseUrl: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
read,
};
@@ -388,14 +366,11 @@ describe('yamlPlaceholderResolver', () => {
});
describe('jsonPlaceholderResolver', () => {
- const read: jest.MockedFunction = jest.fn();
+ const read: jest.MockedFunction = jest.fn();
const params: ResolverParams = {
key: 'a',
value: './file.json',
- location: {
- type: 'github',
- target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
- },
+ baseUrl: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml',
read,
};
diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts
index b811d217d5..91257b68a4 100644
--- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts
@@ -14,49 +14,49 @@
* limitations under the License.
*/
+import { UrlReader } from '@backstage/backend-common';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
import yaml from 'yaml';
-import {
- LocationProcessor,
- LocationProcessorEmit,
- LocationProcessorRead,
-} from './types';
+import { LocationProcessor } from './types';
+
+export type ResolverRead = (url: string) => Promise;
export type ResolverParams = {
key: string;
value: JsonValue;
- location: LocationSpec;
- read: LocationProcessorRead;
+ baseUrl: string;
+ read: ResolverRead;
};
export type PlaceholderResolver = (
params: ResolverParams,
) => Promise;
+type Options = {
+ resolvers: Record;
+ reader: UrlReader;
+};
+
/**
* Traverses raw entity JSON looking for occurrences of $-prefixed placeholders
* that it then fills in with actual data.
*/
export class PlaceholderProcessor implements LocationProcessor {
- static default() {
+ static default({ reader }: { reader: UrlReader }) {
return new PlaceholderProcessor({
- json: jsonPlaceholderResolver,
- yaml: yamlPlaceholderResolver,
- text: textPlaceholderResolver,
+ resolvers: {
+ json: jsonPlaceholderResolver,
+ yaml: yamlPlaceholderResolver,
+ text: textPlaceholderResolver,
+ },
+ reader,
});
}
- constructor(
- private readonly resolvers: Record,
- ) {}
+ constructor(private readonly options: Options) {}
- async processEntity(
- entity: Entity,
- location: LocationSpec,
- _emit: LocationProcessorEmit,
- read: LocationProcessorRead,
- ): Promise {
+ async processEntity(entity: Entity, location: LocationSpec): Promise {
const process = async (data: any): Promise<[any, boolean]> => {
if (!data || !(data instanceof Object)) {
// Scalars can't have placeholders
@@ -90,7 +90,7 @@ export class PlaceholderProcessor implements LocationProcessor {
}
const resolverKey = keys[0].substr(1);
- const resolver = this.resolvers[resolverKey];
+ const resolver = this.options.resolvers[resolverKey];
if (!resolver) {
throw new Error(`Encountered unknown placeholder \$${resolverKey}`);
}
@@ -99,8 +99,8 @@ export class PlaceholderProcessor implements LocationProcessor {
await resolver({
key: resolverKey,
value: data[keys[0]],
- location,
- read,
+ baseUrl: location.target,
+ read: this.options.reader.read.bind(this.options.reader),
}),
true,
];
@@ -171,10 +171,10 @@ export async function textPlaceholderResolver(
*/
async function readTextLocation(params: ResolverParams): Promise {
- const newLocation = relativeLocation(params);
+ const newUrl = relativeUrl(params);
try {
- const data = await params.read(newLocation);
+ const data = await params.read(newUrl);
return data.toString('utf-8');
} catch (e) {
throw new Error(
@@ -183,11 +183,7 @@ async function readTextLocation(params: ResolverParams): Promise {
}
}
-function relativeLocation({
- key,
- value,
- location,
-}: ResolverParams): LocationSpec {
+function relativeUrl({ key, value, baseUrl }: ResolverParams): string {
if (typeof value !== 'string') {
throw new Error(
`Placeholder \$${key} expected a string value parameter, in the form of an absolute URL or a relative path`,
@@ -197,7 +193,7 @@ function relativeLocation({
let url: URL;
try {
// The two-value form of the URL constructor handles relative paths for us
- url = new URL(value, location.target);
+ url = new URL(value, baseUrl);
} catch {
try {
// Check whether value is a valid absolute URL on it's own, if not fail.
@@ -208,13 +204,10 @@ function relativeLocation({
// path traversal attacks and access to any file on the host system. Implementing this
// would require additional security measures.
throw new Error(
- `Placeholder \$${key} could not form an URL out of ${location.target} and ${value}`,
+ `Placeholder \$${key} could not form an URL out of ${baseUrl} and ${value}`,
);
}
}
- return {
- type: location.type,
- target: url.toString(),
- };
+ return url.toString();
}
diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts
index 0ce7f11a5f..c9453d0247 100644
--- a/plugins/catalog-backend/src/ingestion/processors/index.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/index.ts
@@ -20,7 +20,6 @@ export { results };
export * from './types';
export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
-export { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor';
export { AzureApiReaderProcessor } from './AzureApiReaderProcessor';
export { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts
index 3f32a34da4..507da37b39 100644
--- a/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts
@@ -21,7 +21,7 @@
* The RDN is the name of the leftmost attribute that identifies the item; for
* example, for an item with the fully qualified DN
* uid=john,ou=people,ou=spotify,dc=spotify,dc=net the generated entity would
- * have this attribute, with the value "john".
+ * have this annotation, with the value "john".
*/
export const LDAP_RDN_ANNOTATION = 'backstage.io/ldap-rdn';
@@ -31,7 +31,7 @@ export const LDAP_RDN_ANNOTATION = 'backstage.io/ldap-rdn';
*
* The DN is the fully qualified name that identifies the item; for example,
* for an item with the DN uid=john,ou=people,ou=spotify,dc=spotify,dc=net the
- * generated entity would have this attribute, with that full string as its
+ * generated entity would have this annotation, with that full string as its
* value.
*/
export const LDAP_DN_ANNOTATION = 'backstage.io/ldap-dn';
@@ -42,7 +42,7 @@ export const LDAP_DN_ANNOTATION = 'backstage.io/ldap-dn';
*
* The UUID is the globally unique ID that identifies the item; for example,
* for an item with the UUID 76ef928a-b251-1037-9840-d78227f36a7e, the
- * generated entity would have this attribute, with that full string as its
+ * generated entity would have this annotation, with that full string as its
* value.
*/
export const LDAP_UUID_ANNOTATION = 'backstage.io/ldap-uuid';
diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts
index 66359b328b..cd2c423423 100644
--- a/plugins/catalog-backend/src/ingestion/processors/types.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/types.ts
@@ -58,7 +58,6 @@ export type LocationProcessor = {
entity: Entity,
location: LocationSpec,
emit: LocationProcessorEmit,
- read: LocationProcessorRead,
): Promise;
/**
@@ -109,5 +108,3 @@ export type LocationProcessorResult =
| LocationProcessorDataResult
| LocationProcessorEntityResult
| LocationProcessorErrorResult;
-
-export type LocationProcessorRead = (location: LocationSpec) => Promise;
diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts
index 32d7b6aa63..ecf4b0e614 100644
--- a/plugins/catalog-backend/src/service/router.test.ts
+++ b/plugins/catalog-backend/src/service/router.test.ts
@@ -35,6 +35,7 @@ describe('createRouter', () => {
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
+ addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
};
locationsCatalog = {
diff --git a/plugins/catalog-backend/src/util/timing.ts b/plugins/catalog-backend/src/util/timing.ts
new file mode 100644
index 0000000000..1abc446ab4
--- /dev/null
+++ b/plugins/catalog-backend/src/util/timing.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Returns a string with the elapsed time since the start of an operation,
+ * with some human friendly precision, e.g. "133ms" or "14.5s".
+ *
+ * @param startTimestamp The timestamp (from process.hrtime()) at the start ot
+ * the operation
+ */
+export function durationText(startTimestamp: [number, number]): string {
+ const delta = process.hrtime(startTimestamp);
+ const seconds = delta[0] + delta[1] / 1e9;
+ if (seconds > 1) {
+ return `${seconds.toFixed(1)}s`;
+ }
+ return `${(seconds * 1000).toFixed(0)}ms`;
+}
diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts
index a6d86e8102..762f4924c0 100644
--- a/plugins/catalog/src/index.ts
+++ b/plugins/catalog/src/index.ts
@@ -20,6 +20,6 @@ export * from './api/types';
export * from './routes';
export { useEntityCompoundName } from './components/useEntityCompoundName';
export { Router } from './components/Router';
-export { useEntity } from './hooks/useEntity';
+export { useEntity, EntityContext } from './hooks/useEntity';
export { AboutCard } from './components/AboutCard';
export { EntityPageLayout } from './components/EntityPageLayout';
diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md
index 0246da24ed..fbbde1fe65 100644
--- a/plugins/kubernetes-backend/README.md
+++ b/plugins/kubernetes-backend/README.md
@@ -6,6 +6,66 @@ This is the backend part of the Kubernetes plugin.
It responds to Kubernetes requests from the frontend.
-## Links
+## Configuration
-- [The Backstage homepage](https://backstage.io)
+### clusterLocatorMethod
+
+This configures how to determine which clusters a component is running in.
+
+Currently, the only valid locator method is:
+
+#### configMultiTenant
+
+This configuration assumes that all components run on all the provided clusters.
+
+Example:
+
+```yaml
+kubernetes:
+ clusterLocatorMethod: 'configMultiTenant'
+ clusters:
+ - url: http://127.0.0.1:9999
+ name: minikube
+ serviceAccountToken:
+ authProvider: 'serviceAccount'
+ - url: http://127.0.0.2:9999
+ name: gke-cluster-1
+ authProvider: 'google'
+```
+
+##### clusters
+
+Used by the `configMultiTenant` `clusterLocatorMethod` to construct Kubernetes clients.
+
+###### url
+
+The base url to the Kubernetes control plane. Can be found by using the `Kubernetes master` result from running the `kubectl cluster-info` command.
+
+###### name
+
+A name to represent this cluster, this must be unique within the `clusters` array. Users will see this value in the Service Catalog Kubernetes plugin.
+
+###### authProvider
+
+This determines how the Kubernetes client authenticate with the Kubernetes cluster. Valid values are:
+
+| Value | Description |
+| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. |
+| `google` | This will use a user's google auth token from the [google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. |
+
+###### serviceAccount (optional)
+
+The service account token to be used when using the `authProvider`, `serviceAccount`.
+
+## RBAC
+
+The current RBAC permissions required are read-only cluster wide, for the following objects:
+
+- pods
+- services
+- configmaps
+- deployments
+- replicasets
+- horizontalpodautoscalers
+- ingresses
diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md
index 47fc345c73..f97f760c9f 100644
--- a/plugins/kubernetes-backend/examples/dice-roller/README.md
+++ b/plugins/kubernetes-backend/examples/dice-roller/README.md
@@ -1,6 +1,6 @@
# Dice roller
-An app to roll dice (it doesn't actually do that).
+This can be used to run the kubernetes plugin locally against a mock service.
# Viewing in local Minikube running Backstage locally
@@ -23,22 +23,24 @@ An app to roll dice (it doesn't actually do that).
6. Register existing component in Backstage
- https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
-Update `app-config.yaml` as follows.
+Update `app-config.development.yaml` as follows.
```yaml
----
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
clusters:
- url:
name: minikube
serviceAccountToken:
+ authProvider: 'serviceAccount'
```
### Getting the service account token
+Mac copy to clipboard:
+
```
-kubectl get secret DICE_ROLLER_TOKEN_NAME -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
+kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r .secrets[0].name) -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
```
-Paste into `app-config.yaml` `kubernetes.clusters[].serviceAccountToken`
+Paste into `app-config.development.yaml` `kubernetes.clusters[0].serviceAccountToken`
diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts
index 34788d16cb..dd77a11bae 100644
--- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts
+++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts
@@ -43,6 +43,7 @@ describe('MultiTenantConfigClusterLocator', () => {
{
name: 'cluster1',
url: 'http://localhost:8080',
+ authProvider: 'serviceAccount',
},
],
},
@@ -60,6 +61,7 @@ describe('MultiTenantConfigClusterLocator', () => {
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
+ authProvider: 'serviceAccount',
},
]);
});
@@ -70,13 +72,14 @@ describe('MultiTenantConfigClusterLocator', () => {
clusters: [
{
name: 'cluster1',
- serviceAccountToken: undefined,
+ serviceAccountToken: 'token',
url: 'http://localhost:8080',
+ authProvider: 'serviceAccount',
},
{
name: 'cluster2',
- serviceAccountToken: undefined,
url: 'http://localhost:8081',
+ authProvider: 'google',
},
],
},
@@ -92,13 +95,15 @@ describe('MultiTenantConfigClusterLocator', () => {
expect(result).toStrictEqual([
{
name: 'cluster1',
- serviceAccountToken: undefined,
+ serviceAccountToken: 'token',
url: 'http://localhost:8080',
+ authProvider: 'serviceAccount',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
+ authProvider: 'google',
},
]);
});
diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts
index 3d9e2dd8b4..5a1f0f3839 100644
--- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts
+++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts
@@ -28,12 +28,15 @@ export class MultiTenantConfigClusterLocator
}
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
+ // TODO: Add validation that authProvider is required and serviceAccountToken
+ // is required if authProvider is serviceAccount
return new MultiTenantConfigClusterLocator(
config.map(c => {
return {
name: c.getString('name'),
url: c.getString('url'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
+ authProvider: c.getString('authProvider'),
};
}),
);
diff --git a/plugins/kubernetes-backend/src/cluster-locator/types.ts b/plugins/kubernetes-backend/src/cluster-locator/types.ts
index 9b793da558..dae8eb6d60 100644
--- a/plugins/kubernetes-backend/src/cluster-locator/types.ts
+++ b/plugins/kubernetes-backend/src/cluster-locator/types.ts
@@ -15,3 +15,4 @@
*/
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
+export type AuthProviderType = 'google' | 'serviceAccount';
diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts
new file mode 100644
index 0000000000..7f9b2f0bae
--- /dev/null
+++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { KubernetesAuthTranslator } from './types';
+import { AuthRequestBody, ClusterDetails } from '../types/types';
+
+export class GoogleKubernetesAuthTranslator
+ implements KubernetesAuthTranslator {
+ async decorateClusterDetailsWithAuth(
+ clusterDetails: ClusterDetails,
+ requestBody: AuthRequestBody,
+ ): Promise {
+ const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
+ {},
+ clusterDetails,
+ );
+ const authToken: string | undefined = requestBody.auth?.google;
+
+ if (authToken) {
+ clusterDetailsWithAuthToken.serviceAccountToken = authToken;
+ } else {
+ throw new Error(
+ 'Google token not found under auth.google in request body',
+ );
+ }
+ return clusterDetailsWithAuthToken;
+ }
+}
diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts
new file mode 100644
index 0000000000..3b27eb012a
--- /dev/null
+++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { KubernetesAuthTranslator } from './types';
+import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
+import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator';
+import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
+
+describe('getKubernetesAuthTranslatorInstance', () => {
+ const sut = KubernetesAuthTranslatorGenerator;
+
+ it('can return an auth translator for google auth', () => {
+ const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
+ 'google',
+ );
+ expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true);
+ });
+
+ it('can return an auth translator for serviceAccount auth', () => {
+ const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
+ 'serviceAccount',
+ );
+ expect(
+ authTranslator instanceof ServiceAccountKubernetesAuthTranslator,
+ ).toBe(true);
+ });
+
+ it('throws an error when asked for an auth translator for an unsupported auth type', () => {
+ expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow(
+ 'authProvider "linode" has no KubernetesAuthTranslator associated with it',
+ );
+ });
+});
diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts
new file mode 100644
index 0000000000..f7cfc92112
--- /dev/null
+++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { KubernetesAuthTranslator } from './types';
+import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
+import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
+
+export class KubernetesAuthTranslatorGenerator {
+ static getKubernetesAuthTranslatorInstance(
+ authProvider: String,
+ ): KubernetesAuthTranslator {
+ switch (authProvider) {
+ case 'google': {
+ return new GoogleKubernetesAuthTranslator();
+ }
+ case 'serviceAccount': {
+ return new ServiceAccountKubernetesAuthTranslator();
+ }
+ default: {
+ throw new Error(
+ `authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`,
+ );
+ }
+ }
+ }
+}
diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts
new file mode 100644
index 0000000000..ecf2f12b72
--- /dev/null
+++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { KubernetesAuthTranslator } from './types';
+import { AuthRequestBody, ClusterDetails } from '../types/types';
+
+export class ServiceAccountKubernetesAuthTranslator
+ implements KubernetesAuthTranslator {
+ async decorateClusterDetailsWithAuth(
+ clusterDetails: ClusterDetails,
+ // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read.
+ // @ts-ignore-start
+ requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars
+ // @ts-ignore-end
+ ): Promise {
+ return clusterDetails;
+ }
+}
diff --git a/packages/core/src/layout/Sidebar/Settings/AuthProviderList.tsx b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts
similarity index 61%
rename from packages/core/src/layout/Sidebar/Settings/AuthProviderList.tsx
rename to plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts
index 34cf6fb837..f89e04456f 100644
--- a/packages/core/src/layout/Sidebar/Settings/AuthProviderList.tsx
+++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts
@@ -14,16 +14,11 @@
* limitations under the License.
*/
-import React from 'react';
-import List from '@material-ui/core/List';
-import ListSubheader from '@material-ui/core/ListSubheader';
+import { AuthRequestBody, ClusterDetails } from '../types/types';
-type Props = {
- providerSettings: React.ReactNode;
-};
-
-export const AuthProvidersList = ({ providerSettings }: Props) => (
- Available Auth Providers}>
- {providerSettings}
-
-);
+export interface KubernetesAuthTranslator {
+ decorateClusterDetailsWithAuth(
+ clusterDetails: ClusterDetails,
+ requestBody: AuthRequestBody,
+ ): Promise;
+}
diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts
index a3dc67716a..409d41b783 100644
--- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts
+++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts
@@ -33,6 +33,7 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
+ authProvider: 'serviceAccount',
});
expect(result.basePath).toBe('http://localhost:9999');
@@ -55,12 +56,14 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
+ authProvider: 'serviceAccount',
});
const result2 = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
+ authProvider: 'serviceAccount',
});
expect(result1.basePath).toBe('http://localhost:9999');
@@ -89,6 +92,7 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
+ authProvider: 'serviceAccount',
});
expect(result.basePath).toBe('http://localhost:9999');
@@ -111,12 +115,14 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
+ authProvider: 'serviceAccount',
});
const result2 = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
+ authProvider: 'serviceAccount',
});
expect(result1.basePath).toBe('http://localhost:9999');
diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts
index 8f390f309b..6294b3a7ed 100644
--- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts
+++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts
@@ -62,7 +62,8 @@ describe('KubernetesClientProvider', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
- serviceAccountToken: undefined,
+ serviceAccountToken: 'token',
+ authProvider: 'serviceAccount',
},
new Set(['pods', 'services']),
);
@@ -124,7 +125,8 @@ describe('KubernetesClientProvider', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
- serviceAccountToken: undefined,
+ serviceAccountToken: 'token',
+ authProvider: 'serviceAccount',
},
new Set(['pods', 'services']),
);
@@ -172,7 +174,8 @@ describe('KubernetesClientProvider', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
- serviceAccountToken: undefined,
+ serviceAccountToken: 'token',
+ authProvider: 'serviceAccount',
},
new Set(['foo']),
),
diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts
index 56da3235cc..4218c64e80 100644
--- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts
+++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts
@@ -74,6 +74,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
Promise.resolve([
{
name: 'test-cluster',
+ authProvider: 'serviceAccount',
},
]),
);
@@ -89,6 +90,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
getClusterByServiceId,
},
getVoidLogger(),
+ {},
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
@@ -142,9 +144,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
Promise.resolve([
{
name: 'test-cluster',
+ authProvider: 'serviceAccount',
},
{
name: 'other-cluster',
+ authProvider: 'google',
},
]),
);
@@ -160,6 +164,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
getClusterByServiceId,
},
getVoidLogger(),
+ {
+ auth: {
+ google: 'google_token_123',
+ },
+ },
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts
index 1690b8be40..f0b21cbc0c 100644
--- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts
+++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts
@@ -16,17 +16,22 @@
import { Logger } from 'winston';
import {
+ AuthRequestBody,
+ ClusterDetails,
KubernetesClusterLocator,
KubernetesFetcher,
KubernetesObjectTypes,
ObjectsByServiceIdResponse,
-} from '..';
+} from '../types/types';
+import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
+import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
export type GetKubernetesObjectsByServiceIdHandler = (
serviceId: string,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
logger: Logger,
+ requestBody: AuthRequestBody,
objectsToFetch?: Set,
) => Promise;
@@ -46,18 +51,35 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic
fetcher,
clusterLocator,
logger,
+ requestBody,
objectsToFetch = DEFAULT_OBJECTS,
) => {
- const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId);
+ const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId(
+ serviceId,
+ );
+
+ // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
+ const promises: Promise[] = clusterDetails.map(cd => {
+ const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
+ cd.authProvider,
+ );
+ return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
+ cd,
+ requestBody,
+ );
+ });
+ const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all(
+ promises,
+ );
logger.info(
- `serviceId=${serviceId} clusterDetails=[${clusterDetails
+ `serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth
.map(c => c.name)
.join(', ')}]`,
);
return Promise.all(
- clusterDetails.map(cd => {
+ clusterDetailsDecoratedForAuth.map(cd => {
return fetcher
.fetchObjectsByServiceId(serviceId, cd, objectsToFetch)
.then(result => {
diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts
index 7731fe9abb..4e9a206eb4 100644
--- a/plugins/kubernetes-backend/src/service/router.test.ts
+++ b/plugins/kubernetes-backend/src/service/router.test.ts
@@ -54,8 +54,8 @@ describe('router', () => {
jest.resetAllMocks();
});
- describe('GET /services/:serviceId', () => {
- it('happy path: lists kubernetes objects', async () => {
+ describe('post /services/:serviceId', () => {
+ it('happy path: lists kubernetes objects without auth in request body', async () => {
const result = {
clusterOne: {
pods: [
@@ -69,7 +69,34 @@ describe('router', () => {
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
- const response = await request(app).get('/services/test-service');
+ const response = await request(app).post('/services/test-service');
+
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual(result);
+ });
+
+ it('happy path: lists kubernetes objects with auth in request body', async () => {
+ const result = {
+ clusterOne: {
+ pods: [
+ {
+ metadata: {
+ name: 'pod1',
+ },
+ },
+ ],
+ },
+ } as any;
+ handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
+
+ const response = await request(app)
+ .post('/services/test-service')
+ .send({
+ auth: {
+ google: 'google_token_123',
+ },
+ })
+ .set('Content-Type', 'application/json');
expect(response.status).toEqual(200);
expect(response.body).toEqual(result);
@@ -78,7 +105,7 @@ describe('router', () => {
it('internal error: lists kubernetes objects', async () => {
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
- const response = await request(app).get('/services/test-service');
+ const response = await request(app).post('/services/test-service');
expect(response.status).toEqual(500);
expect(response.body).toEqual({ error: 'some internal error' });
diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts
index bdb14bb74d..9e9b4ab2f5 100644
--- a/plugins/kubernetes-backend/src/service/router.ts
+++ b/plugins/kubernetes-backend/src/service/router.ts
@@ -26,7 +26,11 @@ import {
GetKubernetesObjectsByServiceIdHandler,
handleGetKubernetesObjectsByServiceId,
} from './getKubernetesObjectsByServiceIdHandler';
-import { KubernetesClusterLocator, KubernetesFetcher } from '..';
+import {
+ AuthRequestBody,
+ KubernetesClusterLocator,
+ KubernetesFetcher,
+} from '../types/types';
export interface RouterOptions {
logger: Logger;
@@ -62,15 +66,16 @@ export const makeRouter = (
router.use(express.json());
// TODO error handling
- router.get('/services/:serviceId', async (req, res) => {
+ router.post('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
-
+ const requestBody: AuthRequestBody = req.body;
try {
const response = await handleGetByServiceId(
serviceId,
fetcher,
clusterLocator,
logger,
+ requestBody,
);
res.send(response);
} catch (e) {
diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts
index 0cb42eec1a..eb2817f268 100644
--- a/plugins/kubernetes-backend/src/types/types.ts
+++ b/plugins/kubernetes-backend/src/types/types.ts
@@ -27,8 +27,14 @@ import {
export interface ClusterDetails {
name: string;
url: string;
- // TODO this will eventually be configured by the auth translation work
- serviceAccountToken: string | undefined;
+ authProvider: string;
+ serviceAccountToken?: string | undefined;
+}
+
+export interface AuthRequestBody {
+ auth?: {
+ google?: string;
+ };
}
export interface ClusterObjects {
diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md
index 678c9a96a6..1ad478bd89 100644
--- a/plugins/kubernetes/README.md
+++ b/plugins/kubernetes/README.md
@@ -11,3 +11,24 @@ Your plugin has been added to the example app in this repository, meaning you'll
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.
+
+## Surfacing your Kubernetes components as part of an entity
+
+### Adding the entity annotation
+
+In order for Backstage to detect that an entity has Kubernetes components,
+the following annotation should be added to the entity.
+
+```yaml
+annotations:
+ 'backstage.io/kubernetes-id': dice-roller
+```
+
+### Labeling Kubernetes components
+
+In order for Kubernetes components to show up in the service catalog
+as a part of an entity, Kubernetes components must be labeled with the following label:
+
+```yaml
+'backstage.io/kubernetes-id':
+```
diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json
index 11b2f5078d..0fd268da5e 100644
--- a/plugins/kubernetes/package.json
+++ b/plugins/kubernetes/package.json
@@ -21,6 +21,7 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.24",
+ "@backstage/config": "^0.1.1-alpha.24",
"@backstage/core": "^0.1.1-alpha.24",
"@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.24",
"@backstage/theme": "^0.1.1-alpha.24",
diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts
index cbb3b03a38..86bb830046 100644
--- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts
+++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts
@@ -16,7 +16,10 @@
import { DiscoveryApi } from '@backstage/core';
import { KubernetesApi } from './types';
-import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
+import {
+ AuthRequestBody,
+ ObjectsByServiceIdResponse,
+} from '@backstage/plugin-kubernetes-backend';
export class KubernetesBackendClient implements KubernetesApi {
private readonly discoveryApi: DiscoveryApi;
@@ -25,9 +28,18 @@ export class KubernetesBackendClient implements KubernetesApi {
this.discoveryApi = options.discoveryApi;
}
- private async getRequired(path: string): Promise {
+ private async getRequired(
+ path: string,
+ requestBody: AuthRequestBody,
+ ): Promise {
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`;
- const response = await fetch(url);
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ });
if (!response.ok) {
const payload = await response.text();
@@ -40,7 +52,8 @@ export class KubernetesBackendClient implements KubernetesApi {
async getObjectsByServiceId(
serviceId: String,
+ requestBody: AuthRequestBody,
): Promise {
- return await this.getRequired(`/services/${serviceId}`);
+ return await this.getRequired(`/services/${serviceId}`, requestBody);
}
}
diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts
index 5ec3cda6f8..8e44d58e6a 100644
--- a/plugins/kubernetes/src/api/types.ts
+++ b/plugins/kubernetes/src/api/types.ts
@@ -15,7 +15,10 @@
*/
import { createApiRef } from '@backstage/core';
-import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
+import {
+ AuthRequestBody,
+ ObjectsByServiceIdResponse,
+} from '@backstage/plugin-kubernetes-backend';
export const kubernetesApiRef = createApiRef({
id: 'plugin.kubernetes.service',
@@ -24,5 +27,8 @@ export const kubernetesApiRef = createApiRef({
});
export interface KubernetesApi {
- getObjectsByServiceId(serviceId: String): Promise;
+ getObjectsByServiceId(
+ serviceId: String,
+ requestBody: AuthRequestBody,
+ ): Promise;
}
diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx
index 6985423a10..6fa6b3114c 100644
--- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx
+++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx
@@ -76,7 +76,7 @@ describe('ErrorPanel', () => {
expect(getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument();
expect(
getByText(
- "Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR",
+ "Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR, status code: 500",
),
).toBeInTheDocument();
});
diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx
index 1fe5714b09..1b8336dd72 100644
--- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx
+++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx
@@ -29,7 +29,7 @@ const clustersWithErrorsToErrorMessage = (
{c.errors.map((e, j) => {
return (
- {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}`}
+ {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`}
);
})}
diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx
index 6119884c43..013c7ad002 100644
--- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx
+++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx
@@ -16,8 +16,10 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { Grid, TabProps } from '@material-ui/core';
+import { Config } from '@backstage/config';
import {
CardTab,
+ configApiRef,
Content,
Page,
pageTheme,
@@ -28,10 +30,12 @@ import {
import { Entity } from '@backstage/catalog-model';
import { kubernetesApiRef } from '../../api/types';
import {
+ AuthRequestBody,
ClusterObjects,
FetchResponse,
ObjectsByServiceIdResponse,
} from '@backstage/plugin-kubernetes-backend';
+import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types';
import { DeploymentTables } from '../DeploymentTables';
import { DeploymentTriple } from '../../types/types';
import {
@@ -105,16 +109,40 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
>(undefined);
const [error, setError] = useState(undefined);
+ const configApi = useApi(configApiRef);
+ const clusters: Config[] = configApi.getConfigArray('kubernetes.clusters');
+ const allAuthProviders: string[] = clusters.map(c =>
+ c.getString('authProvider'),
+ );
+ const authProviders: string[] = [...new Set(allAuthProviders)];
+
+ const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef);
+
useEffect(() => {
- kubernetesApi
- .getObjectsByServiceId(entity.metadata.name)
- .then(result => {
- setKubernetesObjects(result);
- })
- .catch(e => {
- setError(e.message);
- });
- }, [entity.metadata.name, kubernetesApi]);
+ (async () => {
+ // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
+ let requestBody: AuthRequestBody = {};
+ for (const authProviderStr of authProviders) {
+ // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously
+ requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
+ authProviderStr,
+ requestBody,
+ );
+ }
+
+ // TODO: Add validation on contents/format of requestBody
+ kubernetesApi
+ .getObjectsByServiceId(entity.metadata.name, requestBody)
+ .then(result => {
+ setKubernetesObjects(result);
+ })
+ .catch(e => {
+ setError(e.message);
+ });
+ })();
+ /* eslint-disable react-hooks/exhaustive-deps */
+ }, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]);
+ /* eslint-enable react-hooks/exhaustive-deps */
const clustersWithErrors =
kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? [];
diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts
new file mode 100644
index 0000000000..7de5c7c0a9
--- /dev/null
+++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { OAuthApi } from '@backstage/core';
+import { KubernetesAuthProvider } from './types';
+import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend';
+
+export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider {
+ authProvider: OAuthApi;
+
+ constructor(authProvider: OAuthApi) {
+ this.authProvider = authProvider;
+ }
+
+ async decorateRequestBodyForAuth(
+ requestBody: AuthRequestBody,
+ ): Promise {
+ const googleAuthToken: string = await this.authProvider.getAccessToken(
+ 'https://www.googleapis.com/auth/cloud-platform',
+ );
+ if ('auth' in requestBody) {
+ requestBody.auth!.google = googleAuthToken;
+ } else {
+ requestBody.auth = { google: googleAuthToken };
+ }
+ return requestBody;
+ }
+}
diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts
new file mode 100644
index 0000000000..ac909d6695
--- /dev/null
+++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 { OAuthApi } from '@backstage/core';
+import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend';
+import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types';
+import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider';
+import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider';
+
+export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
+ private readonly kubernetesAuthProviderMap: Map<
+ string,
+ KubernetesAuthProvider
+ >;
+
+ constructor(options: { googleAuthApi: OAuthApi }) {
+ this.kubernetesAuthProviderMap = new Map();
+ this.kubernetesAuthProviderMap.set(
+ 'google',
+ new GoogleKubernetesAuthProvider(options.googleAuthApi),
+ );
+ this.kubernetesAuthProviderMap.set(
+ 'serviceAccount',
+ new ServiceAccountKubernetesAuthProvider(),
+ );
+ }
+
+ async decorateRequestBodyForAuth(
+ authProvider: string,
+ requestBody: AuthRequestBody,
+ ): Promise {
+ const kubernetesAuthProvider:
+ | KubernetesAuthProvider
+ | undefined = this.kubernetesAuthProviderMap.get(authProvider);
+ if (kubernetesAuthProvider) {
+ return await kubernetesAuthProvider.decorateRequestBodyForAuth(
+ requestBody,
+ );
+ }
+ throw new Error(
+ `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`,
+ );
+ }
+}
diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts
new file mode 100644
index 0000000000..3ac5a9494b
--- /dev/null
+++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { KubernetesAuthProvider } from './types';
+import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend';
+
+export class ServiceAccountKubernetesAuthProvider
+ implements KubernetesAuthProvider {
+ async decorateRequestBodyForAuth(
+ requestBody: AuthRequestBody,
+ ): Promise {
+ // No-op, with service account for auth, cluster config/details should already have serviceAccountToken
+ return requestBody;
+ }
+}
diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts
new file mode 100644
index 0000000000..24fee0f94c
--- /dev/null
+++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts
@@ -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.
+ */
+
+import { createApiRef } from '@backstage/core';
+import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend';
+
+export interface KubernetesAuthProvider {
+ decorateRequestBodyForAuth(
+ requestBody: AuthRequestBody,
+ ): Promise;
+}
+
+export const kubernetesAuthProvidersApiRef = createApiRef<
+ KubernetesAuthProvidersApi
+>({
+ id: 'plugin.kubernetes-auth-providers.service',
+ description: 'Used by the Kubernetes plugin to fetch KubernetesAuthProviders',
+});
+
+export interface KubernetesAuthProvidersApi {
+ decorateRequestBodyForAuth(
+ authProvider: string,
+ requestBody: AuthRequestBody,
+ ): Promise;
+}
diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts
index b7ad9615f5..cff9e1468b 100644
--- a/plugins/kubernetes/src/plugin.ts
+++ b/plugins/kubernetes/src/plugin.ts
@@ -18,9 +18,12 @@ import {
createPlugin,
createRouteRef,
discoveryApiRef,
+ googleAuthApiRef,
} from '@backstage/core';
import { KubernetesBackendClient } from './api/KubernetesBackendClient';
import { kubernetesApiRef } from './api/types';
+import { kubernetesAuthProvidersApiRef } from './kubernetes-auth-provider/types';
+import { KubernetesAuthProviders } from './kubernetes-auth-provider/KubernetesAuthProviders';
export const rootCatalogKubernetesRouteRef = createRouteRef({
path: '*',
@@ -36,5 +39,12 @@ export const plugin = createPlugin({
factory: ({ discoveryApi }) =>
new KubernetesBackendClient({ discoveryApi }),
}),
+ createApiFactory({
+ api: kubernetesAuthProvidersApiRef,
+ deps: { googleAuthApi: googleAuthApiRef },
+ factory: ({ googleAuthApi }) => {
+ return new KubernetesAuthProviders({ googleAuthApi });
+ },
+ }),
],
});
diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md
index b5fecb85f8..19244f3cf0 100644
--- a/plugins/lighthouse/README.md
+++ b/plugins/lighthouse/README.md
@@ -57,3 +57,66 @@ Then configure the lighthouse service url in your [`app-config.yaml`](https://gi
lighthouse:
baseUrl: http://your-service-url
```
+
+### Integration with the Catalog
+
+The lighthouse plugin can be integrated into the catalog so that lighthouse audit information relating to a component
+can be displayed within that component's entity page. In order to link an Entity to its lighthouse audits the entity
+must be annotated as follows:
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ # ...
+ annotations:
+ # ...
+ lighthouse.com/website-url: # A single website url e.g. https://backstage.io/
+```
+
+> NOTE: The lighthouse plugin only supports one website url per component at this time.
+
+Add a lighthouse tab to the EntityPage:
+
+```tsx
+// packages/app/src/components/catalog/EntityPage.tsx
+import { EmbeddedRouter as LighthouseRouter } from '@backstage/plugin-lighthouse';
+
+// ...
+const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
+
+ // ...
+ }
+ />
+
+);
+```
+
+> NOTE: The embedded router renders page content without a header section allowing it to be rendered within a
+> catalog plugin page.
+
+Add a Lighthouse card to the overview tab on the EntityPage:
+
+```tsx
+// packages/app/src/components/catalog/EntityPage.tsx
+import {
+ LastLighthouseAuditCard,
+ isPluginApplicableToEntity as isLighthouseAvailable,
+} from '@backstage/plugin-lighthouse';
+
+// ...
+
+const OverviewContent = ({ entity }: { entity: Entity }) => (
+
+ // ...
+ {isLighthouseAvailable(entity) && (
+
+
+
+ )}
+
+);
+```
diff --git a/plugins/lighthouse/constants.ts b/plugins/lighthouse/constants.ts
new file mode 100644
index 0000000000..7a60e2be67
--- /dev/null
+++ b/plugins/lighthouse/constants.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const LIGHTHOUSE_WEBSITE_URL_ANNOTATION = 'lighthouse.com/website-url';
diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json
index 6b26cf0d3b..7bcf98ab2c 100644
--- a/plugins/lighthouse/package.json
+++ b/plugins/lighthouse/package.json
@@ -21,17 +21,22 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
+ "@backstage/catalog-model": "^0.1.1-alpha.24",
"@backstage/config": "^0.1.1-alpha.24",
"@backstage/core": "^0.1.1-alpha.24",
+ "@backstage/core-api": "^0.1.1-alpha.24",
+ "@backstage/plugin-catalog": "^0.1.1-alpha.24",
"@backstage/theme": "^0.1.1-alpha.24",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
+ "@testing-library/react-hooks": "^3.4.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-markdown": "^4.3.1",
"react-router-dom": "6.0.0-beta.0",
- "react-use": "^15.3.3"
+ "react-use": "^15.3.3",
+ "@types/react": "^16.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.24",
diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx
index 46843504fd..df481483c3 100644
--- a/plugins/lighthouse/src/Router.tsx
+++ b/plugins/lighthouse/src/Router.tsx
@@ -15,11 +15,18 @@
*/
import React from 'react';
-import { Routes, Route } from 'react-router-dom';
-import { rootRouteRef, viewAuditRouteRef, createAuditRouteRef } from './plugin';
+import { Route, Routes } from 'react-router-dom';
+import { createAuditRouteRef, rootRouteRef, viewAuditRouteRef } from './plugin';
import AuditList from './components/AuditList';
-import AuditView from './components/AuditView';
-import CreateAudit from './components/CreateAudit';
+import AuditView, { AuditViewContent } from './components/AuditView';
+import CreateAudit, { CreateAuditContent } from './components/CreateAudit';
+import { Entity } from '@backstage/catalog-model';
+import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants';
+import { AuditListForEntity } from './components/AuditList/AuditListForEntity';
+import { EmptyState } from '@backstage/core';
+
+export const isPluginApplicableToEntity = (entity: Entity) =>
+ Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]);
export const Router = () => (
@@ -28,3 +35,24 @@ export const Router = () => (
} />
);
+
+export const EmbeddedRouter = ({ entity }: { entity: Entity }) =>
+ !isPluginApplicableToEntity(entity) ? (
+
+ ) : (
+
+ } />
+ }
+ />
+ }
+ />
+
+ );
diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts
index 5b6cd9aae7..03b42a38b9 100644
--- a/plugins/lighthouse/src/api.ts
+++ b/plugins/lighthouse/src/api.ts
@@ -104,6 +104,7 @@ export type LighthouseApi = {
getWebsiteList: (listOptions: LASListRequest) => Promise;
getWebsiteForAuditId: (auditId: string) => Promise;
triggerAudit: (payload: TriggerAuditPayload) => Promise;
+ getWebsiteByUrl: (websiteUrl: string) => Promise;
};
export const lighthouseApiRef = createApiRef({
@@ -150,4 +151,10 @@ export class LighthouseRestApi implements LighthouseApi {
},
});
}
+
+ async getWebsiteByUrl(websiteUrl: string): Promise {
+ return this.fetch(
+ `/v1/websites/${encodeURIComponent(websiteUrl)}`,
+ );
+ }
}
diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx
new file mode 100644
index 0000000000..7f10914d9c
--- /dev/null
+++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
+import {
+ lighthouseApiRef,
+ LighthouseRestApi,
+ WebsiteListResponse,
+} from '../../api';
+import mockFetch from 'jest-fetch-mock';
+
+import * as data from '../../__fixtures__/website-list-response.json';
+import { EntityContext } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import { AuditListForEntity } from './AuditListForEntity';
+import { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+import { MemoryRouter } from 'react-router-dom';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+
+jest.mock('../../hooks/useWebsiteForEntity', () => ({
+ useWebsiteForEntity: jest.fn(),
+}));
+
+const websiteListResponse = data as WebsiteListResponse;
+const entityWebsite = websiteListResponse.items[0];
+
+describe(' ', () => {
+ let apis: ApiRegistry;
+
+ const mockErrorApi: jest.Mocked = {
+ post: jest.fn(),
+ error$: jest.fn(),
+ };
+
+ beforeEach(() => {
+ apis = ApiRegistry.from([
+ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')],
+ [errorApiRef, mockErrorApi],
+ ]);
+ mockFetch.mockResponse(JSON.stringify(entityWebsite));
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: entityWebsite,
+ loading: false,
+ error: null,
+ });
+ });
+
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'software',
+ annotations: {
+ 'lighthouse.com/website-url': entityWebsite.url,
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'Website',
+ lifecycle: 'development',
+ },
+ };
+
+ const subject = (value = {}) =>
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ it('renders the audit list for the entity', async () => {
+ const { findByText } = subject();
+ expect(await findByText(entityWebsite.url)).toBeInTheDocument();
+ });
+
+ describe('where the data is loading', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: true,
+ error: null,
+ });
+ });
+
+ it('renders a Progress element', async () => {
+ const { findByTestId } = subject();
+ expect(await findByTestId('progress')).toBeInTheDocument();
+ });
+ });
+
+ describe('where there is an error loading data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: 'error',
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+
+ describe('where there is not data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+});
diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx
new file mode 100644
index 0000000000..616fd0fa8a
--- /dev/null
+++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { AuditListTable } from './AuditListTable';
+import { Progress } from '@backstage/core';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+
+export const AuditListForEntity = () => {
+ const { value, loading, error } = useWebsiteForEntity();
+ if (loading) {
+ return ;
+ }
+ if (error || !value) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx
index d0e743a77f..54bd179418 100644
--- a/plugins/lighthouse/src/components/AuditView/index.tsx
+++ b/plugins/lighthouse/src/components/AuditView/index.tsx
@@ -124,7 +124,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
);
};
-const ConnectedAuditView: FC<{}> = () => {
+export const AuditViewContent: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const params = useParams() as { id: string };
const classes = useStyles();
@@ -173,32 +173,35 @@ const ConnectedAuditView: FC<{}> = () => {
}
return (
-
-
-
- navigate(`../../${createAuditButtonUrl}`)}
>
- navigate(`../../${createAuditButtonUrl}`)}
- >
- Create New Audit
-
-
-
- {content}
-
-
+ Create New Audit
+
+
+
+ {content}
+ >
);
};
+const ConnectedAuditView = () => (
+
+
+
+
+
+
+);
+
export default ConnectedAuditView;
diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx
new file mode 100644
index 0000000000..20b3b9c072
--- /dev/null
+++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import {
+ AuditCompleted,
+ LighthouseCategoryId,
+ WebsiteListResponse,
+} from '../../api';
+import { EntityContext } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import { LastLighthouseAuditCard } from './LastLighthouseAuditCard';
+import { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+import { MemoryRouter } from 'react-router-dom';
+import * as data from '../../__fixtures__/website-list-response.json';
+
+jest.mock('../../hooks/useWebsiteForEntity', () => ({
+ useWebsiteForEntity: jest.fn(),
+}));
+
+const websiteListResponse = data as WebsiteListResponse;
+let entityWebsite = websiteListResponse.items[2];
+
+describe(' ', () => {
+ const asPercentage = (fraction: number) => `${fraction * 100}%`;
+
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: entityWebsite,
+ loading: false,
+ error: null,
+ });
+ });
+
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'software',
+ annotations: {
+ 'lighthouse.com/website-url': entityWebsite.url,
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'Website',
+ lifecycle: 'development',
+ },
+ };
+
+ const subject = (value = {}) =>
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ describe('where the last audit completed successfully', () => {
+ const audit = entityWebsite.lastAudit as AuditCompleted;
+
+ it('renders the performance data for the audit', async () => {
+ const { findByText } = subject();
+ expect(await findByText(audit.url)).toBeInTheDocument();
+ expect(await findByText(audit.status)).toBeInTheDocument();
+ for (const category of Object.keys(audit.categories)) {
+ const { score } = audit.categories[category as LighthouseCategoryId];
+ expect(await findByText(asPercentage(score))).toBeInTheDocument();
+ }
+ });
+
+ describe('where a category score is not a number', () => {
+ beforeEach(() => {
+ entityWebsite = { ...entityWebsite };
+ (entityWebsite.lastAudit as AuditCompleted).categories.accessibility.score = NaN;
+ });
+
+ afterEach(() => {
+ entityWebsite = websiteListResponse.items[2];
+ });
+
+ it('renders the performance data for the audit', async () => {
+ const { findByText } = subject();
+ expect(await findByText('N/A')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('where the last audit is in running', () => {
+ const audit = websiteListResponse.items[0].lastAudit as AuditCompleted;
+
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: websiteListResponse.items[0],
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders the url and status of the audit', async () => {
+ const { findByText } = subject();
+ expect(await findByText(audit.url)).toBeInTheDocument();
+ expect(await findByText(audit.status)).toBeInTheDocument();
+ });
+ });
+
+ describe('where the data is loading', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: true,
+ error: null,
+ });
+ });
+
+ it('renders a Progress element', async () => {
+ const { findByTestId } = subject();
+ expect(await findByTestId('progress')).toBeInTheDocument();
+ });
+ });
+
+ describe('where there is an error loading data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: 'error',
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+ //
+ describe('where there is no data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+});
diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx
new file mode 100644
index 0000000000..d3bc362877
--- /dev/null
+++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx
@@ -0,0 +1,109 @@
+/*
+ * 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, { FC } from 'react';
+import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api';
+import {
+ InfoCard,
+ Progress,
+ StatusError,
+ StatusOK,
+ StatusWarning,
+ StructuredMetadataTable,
+} from '@backstage/core';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+import AuditStatusIcon from '../AuditStatusIcon';
+
+const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => {
+ const scoreAsPercentage = score * 100;
+ switch (true) {
+ case scoreAsPercentage >= 90:
+ return (
+ <>
+
+ {scoreAsPercentage}%
+ >
+ );
+ case scoreAsPercentage >= 50 && scoreAsPercentage < 90:
+ return (
+ <>
+
+ {scoreAsPercentage}%
+ >
+ );
+ case scoreAsPercentage < 50:
+ return (
+ <>
+
+ {scoreAsPercentage}%
+ >
+ );
+ default:
+ return N/A ;
+ }
+};
+
+const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => (
+ <>
+
+ {audit.status.toUpperCase()}
+ >
+);
+
+const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({
+ audit,
+ dense = false,
+}) => {
+ const { url } = audit;
+ const flattenedCategoryData: Record = {};
+ if (audit.status === 'COMPLETED') {
+ const categories = (audit as AuditCompleted).categories;
+ const categoryIds = Object.keys(categories) as LighthouseCategoryId[];
+ categoryIds.forEach((id: LighthouseCategoryId) => {
+ const { title, score } = categories[id];
+
+ flattenedCategoryData[title] = (
+
+ );
+ });
+ }
+ const tableData = {
+ url,
+ status: ,
+ ...flattenedCategoryData,
+ };
+
+ return ;
+};
+
+export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
+ dense = false,
+}) => {
+ const { value: website, loading, error } = useWebsiteForEntity();
+
+ let content;
+ if (loading) {
+ content = ;
+ }
+ if (error) {
+ content = null;
+ }
+ if (website) {
+ content = (
+
+ );
+ }
+ return {content} ;
+};
diff --git a/plugins/lighthouse/src/components/Cards/index.ts b/plugins/lighthouse/src/components/Cards/index.ts
new file mode 100644
index 0000000000..7595619808
--- /dev/null
+++ b/plugins/lighthouse/src/components/Cards/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { LastLighthouseAuditCard } from './LastLighthouseAuditCard';
diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx
index 36bd97485a..ad46b06020 100644
--- a/plugins/lighthouse/src/components/CreateAudit/index.tsx
+++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx
@@ -53,7 +53,7 @@ const useStyles = makeStyles(theme => ({
},
}));
-const CreateAudit: FC<{}> = () => {
+export const CreateAuditContent: FC<{}> = () => {
const errorApi = useApi(errorApiRef);
const lighthouseApi = useApi(lighthouseApiRef);
const classes = useStyles();
@@ -94,88 +94,91 @@ const CreateAudit: FC<{}> = () => {
]);
return (
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+ >
);
};
+const CreateAudit = () => (
+
+
+
+
+
+
+);
+
export default CreateAudit;
diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx
new file mode 100644
index 0000000000..868e26f04c
--- /dev/null
+++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api';
+import { lighthouseApiRef, WebsiteListResponse } from '../api';
+import { useWebsiteForEntity } from './useWebsiteForEntity';
+import { EntityContext } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import * as data from '../__fixtures__/website-list-response.json';
+
+const websiteListResponse = data as WebsiteListResponse;
+const website = websiteListResponse.items[0];
+
+const mockErrorApi: jest.Mocked = {
+ post: jest.fn(),
+ error$: jest.fn(),
+};
+
+const mockLighthouseApi: jest.Mocked> = {
+ getWebsiteByUrl: jest.fn(),
+};
+
+describe('useWebsiteForEntity', () => {
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'software',
+ annotations: {
+ 'lighthouse.com/website-url': website.url,
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'Website',
+ lifecycle: 'development',
+ },
+ };
+
+ const wrapper: React.FC<{}> = ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ );
+ };
+
+ const subject = () =>
+ renderHook(useWebsiteForEntity, {
+ wrapper,
+ });
+
+ beforeEach(() => {
+ (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockResolvedValue(website);
+ });
+
+ it('returns the lighthouse information for the website url in annotations ', async () => {
+ const { result, waitForNextUpdate } = subject();
+ await waitForNextUpdate();
+ expect(result.current?.value).toBe(website);
+ });
+
+ describe('where there is an error', () => {
+ const error = new Error('useWebsiteForEntity unit test');
+
+ beforeEach(() => {
+ (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockRejectedValueOnce(
+ error,
+ );
+ });
+
+ it('posts the error to the error api and returns the error to the caller', async () => {
+ const { result, waitForNextUpdate } = subject();
+ await waitForNextUpdate();
+ expect(result.current?.error).toBe(error);
+ expect(mockErrorApi.post).toHaveBeenCalledWith(error);
+ });
+ });
+});
diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts
new file mode 100644
index 0000000000..c52e38f473
--- /dev/null
+++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts
@@ -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 { useEntity } from '@backstage/plugin-catalog';
+import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants';
+import { errorApiRef, useApi } from '@backstage/core-api';
+import { lighthouseApiRef } from '../api';
+import { useAsync } from 'react-use';
+
+// For the sake of simplicity we assume that an entity has only one website url. This is to avoid encoding a list
+// type in an annotation which is a plain string.
+export const useWebsiteForEntity = () => {
+ const { entity } = useEntity();
+ const websiteUrl =
+ entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION] ?? '';
+ const lighthouseApi = useApi(lighthouseApiRef);
+ const errorApi = useApi(errorApiRef);
+ const response = useAsync(() => lighthouseApi.getWebsiteByUrl(websiteUrl), [
+ websiteUrl,
+ ]);
+ if (response.error) {
+ errorApi.post(response.error);
+ }
+ return response;
+};
diff --git a/plugins/lighthouse/src/index.ts b/plugins/lighthouse/src/index.ts
index bebdaaf713..64fe2f8cc0 100644
--- a/plugins/lighthouse/src/index.ts
+++ b/plugins/lighthouse/src/index.ts
@@ -15,5 +15,6 @@
*/
export { plugin } from './plugin';
-export { Router } from './Router';
+export { Router, isPluginApplicableToEntity, EmbeddedRouter } from './Router';
export * from './api';
+export * from './components/Cards';
diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json
index 248c3a4014..5630f36fcb 100644
--- a/plugins/proxy-backend/package.json
+++ b/plugins/proxy-backend/package.json
@@ -22,7 +22,6 @@
"@backstage/backend-common": "^0.1.1-alpha.24",
"@backstage/config": "^0.1.1-alpha.24",
"@types/express": "^4.17.6",
- "@types/http-proxy-middleware": "^0.19.3",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"http-proxy-middleware": "^0.19.1",
@@ -35,6 +34,8 @@
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.24",
+ "@types/http-proxy-middleware": "^0.19.3",
+ "@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
"@types/yup": "^0.28.2",
"supertest": "^4.0.2"
diff --git a/plugins/proxy-backend/src/index.ts b/plugins/proxy-backend/src/index.ts
index 7612c392a2..de2be19b6d 100644
--- a/plugins/proxy-backend/src/index.ts
+++ b/plugins/proxy-backend/src/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export * from './service/router';
+export * from './service';
diff --git a/packages/core/src/layout/Sidebar/Settings/index.ts b/plugins/proxy-backend/src/service/index.ts
similarity index 83%
rename from packages/core/src/layout/Sidebar/Settings/index.ts
rename to plugins/proxy-backend/src/service/index.ts
index 15042dc85d..38fbb697c4 100644
--- a/packages/core/src/layout/Sidebar/Settings/index.ts
+++ b/plugins/proxy-backend/src/service/index.ts
@@ -14,5 +14,4 @@
* limitations under the License.
*/
-export { ProviderSettingsItem } from './ProviderSettingsItem';
-export { SidebarUserSettings } from './UserSettings';
+export { createRouter } from './router';
diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts
index 07737e9ea8..012adaed93 100644
--- a/plugins/proxy-backend/src/service/router.test.ts
+++ b/plugins/proxy-backend/src/service/router.test.ts
@@ -14,13 +14,30 @@
* limitations under the License.
*/
-import { createRouter } from './router';
+import { buildMiddleware, createRouter } from './router';
import * as winston from 'winston';
import { ConfigReader } from '@backstage/config';
import {
loadBackendConfig,
SingleHostDiscovery,
} from '@backstage/backend-common';
+import createProxyMiddleware, {
+ Config as ProxyMiddlewareConfig,
+ Proxy,
+} from 'http-proxy-middleware';
+import * as http from 'http';
+
+jest.mock('http-proxy-middleware', () => {
+ return jest.fn().mockImplementation(
+ (): Proxy => {
+ return () => undefined;
+ },
+ );
+});
+
+const mockCreateProxyMiddleware = createProxyMiddleware as jest.MockedFunction<
+ typeof createProxyMiddleware
+>;
describe('createRouter', () => {
it('works', async () => {
@@ -35,3 +52,151 @@ describe('createRouter', () => {
expect(router).toBeDefined();
});
});
+
+describe('buildMiddleware', () => {
+ const logger = winston.createLogger();
+
+ beforeEach(() => {
+ mockCreateProxyMiddleware.mockClear();
+ });
+
+ it('accepts strings', async () => {
+ buildMiddleware('/api/', logger, 'test', 'http://mocked');
+
+ expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
+
+ const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [
+ (pathname: string, req: Partial) => boolean,
+ ProxyMiddlewareConfig,
+ ];
+ expect(filter('', { method: 'GET' })).toBe(true);
+ expect(filter('', { method: 'POST' })).toBe(true);
+ expect(filter('', { method: 'PUT' })).toBe(true);
+ expect(filter('', { method: 'PATCH' })).toBe(true);
+ expect(filter('', { method: 'DELETE' })).toBe(true);
+
+ expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' });
+ expect(fullConfig.changeOrigin).toBe(true);
+ expect(fullConfig.logProvider!(logger)).toBe(logger);
+ });
+
+ it('limits allowedMethods', async () => {
+ buildMiddleware('/api/', logger, 'test', {
+ target: 'http://mocked',
+ allowedMethods: ['GET', 'DELETE'],
+ });
+
+ expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
+
+ const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [
+ (pathname: string, req: Partial) => boolean,
+ ProxyMiddlewareConfig,
+ ];
+ expect(filter('', { method: 'GET' })).toBe(true);
+ expect(filter('', { method: 'POST' })).toBe(false);
+ expect(filter('', { method: 'PUT' })).toBe(false);
+ expect(filter('', { method: 'PATCH' })).toBe(false);
+ expect(filter('', { method: 'DELETE' })).toBe(true);
+
+ expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' });
+ expect(fullConfig.changeOrigin).toBe(true);
+ expect(fullConfig.logProvider!(logger)).toBe(logger);
+ });
+
+ it('permits default headers', async () => {
+ buildMiddleware('/api/', logger, 'test', {
+ target: 'http://mocked',
+ });
+
+ expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
+
+ const config = mockCreateProxyMiddleware.mock
+ .calls[0][1] as ProxyMiddlewareConfig;
+
+ const testClientRequest = {
+ getHeaderNames: () => [
+ 'cache-control',
+ 'content-language',
+ 'content-length',
+ 'content-type',
+ 'expires',
+ 'last-modified',
+ 'pragma',
+ 'host',
+ 'accept',
+ 'accept-language',
+ 'user-agent',
+ 'cookie',
+ ],
+ removeHeader: jest.fn(),
+ } as Partial;
+
+ expect(config).toBeDefined();
+ expect(config.onProxyReq).toBeDefined();
+
+ config.onProxyReq!(
+ testClientRequest as http.ClientRequest,
+ {} as http.IncomingMessage,
+ {} as http.ServerResponse,
+ );
+
+ expect(testClientRequest.removeHeader).toHaveBeenCalledTimes(1);
+ expect(testClientRequest.removeHeader).toHaveBeenCalledWith('cookie');
+ });
+
+ it('permits default and configured headers', async () => {
+ buildMiddleware('/api/', logger, 'test', {
+ target: 'http://mocked',
+ headers: {
+ Authorization: 'my-token',
+ },
+ });
+
+ expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
+
+ const config = mockCreateProxyMiddleware.mock
+ .calls[0][1] as ProxyMiddlewareConfig;
+
+ const testClientRequest = {
+ getHeaderNames: () => ['authorization', 'Cookie'],
+ removeHeader: jest.fn(),
+ } as Partial;
+
+ config.onProxyReq!(
+ testClientRequest as http.ClientRequest,
+ {} as http.IncomingMessage,
+ {} as http.ServerResponse,
+ );
+
+ expect(testClientRequest.removeHeader).toHaveBeenCalledTimes(1);
+ expect(testClientRequest.removeHeader).toHaveBeenCalledWith('Cookie');
+ });
+
+ it('permits configured headers', async () => {
+ buildMiddleware('/api/', logger, 'test', {
+ target: 'http://mocked',
+ allowedHeaders: ['authorization', 'cookie'],
+ });
+
+ expect(createProxyMiddleware).toHaveBeenCalledTimes(1);
+
+ const config = mockCreateProxyMiddleware.mock
+ .calls[0][1] as ProxyMiddlewareConfig;
+
+ const testClientRequest = {
+ getHeaderNames: () => ['authorization', 'Cookie', 'X-Auth-Request-User'],
+ removeHeader: jest.fn(),
+ } as Partial;
+
+ config.onProxyReq!(
+ testClientRequest as http.ClientRequest,
+ {} as http.IncomingMessage,
+ {} as http.ServerResponse,
+ );
+
+ expect(testClientRequest.removeHeader).toHaveBeenCalledTimes(1);
+ expect(testClientRequest.removeHeader).toHaveBeenCalledWith(
+ 'X-Auth-Request-User',
+ );
+ });
+});
diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts
index 8cec31719c..75eab029b5 100644
--- a/plugins/proxy-backend/src/service/router.ts
+++ b/plugins/proxy-backend/src/service/router.ts
@@ -25,6 +25,27 @@ import { Logger } from 'winston';
import http from 'http';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
+// A list of headers that are always forwarded to the proxy targets.
+const safeForwardHeaders = [
+ // https://fetch.spec.whatwg.org/#cors-safelisted-request-header
+ 'cache-control',
+ 'content-language',
+ 'content-length',
+ 'content-type',
+ 'expires',
+ 'last-modified',
+ 'pragma',
+
+ // host is overridden by default. if changeOrigin is configured to false,
+ // we assume this is a intentional and should also be forwarded.
+ 'host',
+
+ // other headers that we assume to be ok
+ 'accept',
+ 'accept-language',
+ 'user-agent',
+];
+
export interface RouterOptions {
logger: Logger;
config: Config;
@@ -33,11 +54,12 @@ export interface RouterOptions {
export interface ProxyConfig extends ProxyMiddlewareConfig {
allowedMethods?: string[];
+ allowedHeaders?: string[];
}
// Creates a proxy middleware, possibly with defaults added on top of the
// given config.
-function buildMiddleware(
+export function buildMiddleware(
pathPrefix: string,
logger: Logger,
route: string,
@@ -68,6 +90,30 @@ function buildMiddleware(
return fullConfig?.allowedMethods?.includes(req.method!) ?? true;
};
+ // Only forward the allowed HTTP headers to not forward unwanted secret headers
+ const headerAllowList = new Set(
+ [
+ // allow all safe headers
+ ...safeForwardHeaders,
+
+ // allow all headers that are set by the proxy
+ ...((fullConfig.headers && Object.keys(fullConfig.headers)) || []),
+
+ // allow all configured headers
+ ...(fullConfig.allowedHeaders || []),
+ ].map(h => h.toLocaleLowerCase()),
+ );
+
+ fullConfig.onProxyReq = (proxyReq: http.ClientRequest) => {
+ const headerNames = proxyReq.getHeaderNames();
+
+ headerNames.forEach(h => {
+ if (!headerAllowList.has(h.toLocaleLowerCase())) {
+ proxyReq.removeHeader(h);
+ }
+ });
+ };
+
return createProxyMiddleware(filter, fullConfig);
}
diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json
index ff584e7374..b146609d8d 100644
--- a/plugins/scaffolder-backend/package.json
+++ b/plugins/scaffolder-backend/package.json
@@ -36,7 +36,7 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
- "git-url-parse": "^11.2.0",
+ "git-url-parse": "^11.3.0",
"globby": "^11.0.0",
"helmet": "^4.0.0",
"jsonschema": "^1.2.6",
diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts
index 4409d815b5..babdd65c04 100644
--- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts
+++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts
@@ -90,7 +90,6 @@ describe('GitHubPreparer', () => {
{},
);
});
-
it('return the temp directory with the path to the folder if it is specified', async () => {
const preparer = new GithubPreparer();
mockEntity.spec.path = './template/test/1/2/3';
@@ -100,4 +99,20 @@ describe('GitHubPreparer', () => {
/\/template\/test\/1\/2\/3$/,
);
});
+ it('calls the clone command with the token when provided', async () => {
+ const preparer = new GithubPreparer({ token: 'abc' });
+ await preparer.prepare(mockEntity);
+ expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
+ 1,
+ 'https://github.com/benjdlambert/backstage-graphql-template',
+ expect.any(String),
+ {
+ fetchOpts: {
+ callbacks: {
+ credentials: expect.any(Function),
+ },
+ },
+ },
+ );
+ });
});
diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts
index ba4adcabd9..49703c5616 100644
--- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts
+++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts
@@ -21,11 +21,18 @@ import { parseLocationAnnotation } from '../helpers';
import { InputError } from '@backstage/backend-common';
import { PreparerBase } from './types';
import GitUriParser from 'git-url-parse';
-import { Clone } from 'nodegit';
+import { Clone, Cred } from 'nodegit';
export class GithubPreparer implements PreparerBase {
+ token?: string;
+
+ constructor(params: { token?: string } = {}) {
+ this.token = params.token;
+ }
+
async prepare(template: TemplateEntityV1alpha1): Promise {
const { protocol, location } = parseLocationAnnotation(template);
+ const { token } = this;
if (protocol !== 'github') {
throw new InputError(
@@ -45,9 +52,19 @@ export class GithubPreparer implements PreparerBase {
template.spec.path ?? '.',
);
- await Clone.clone(repositoryCheckoutUrl, tempDir, {
- // TODO(blam): Maybe need some auth here?
- });
+ const cloneOptions = token
+ ? {
+ fetchOpts: {
+ callbacks: {
+ credentials() {
+ return Cred.userpassPlaintextNew(token, 'x-oauth-basic');
+ },
+ },
+ },
+ }
+ : {};
+
+ await Clone.clone(repositoryCheckoutUrl, tempDir, cloneOptions);
return path.resolve(tempDir, templateDirectory);
}
diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json
index ed2b952f16..f12e95bc22 100644
--- a/plugins/techdocs-backend/package.json
+++ b/plugins/techdocs-backend/package.json
@@ -31,7 +31,7 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.1",
- "git-url-parse": "^11.2.0",
+ "git-url-parse": "^11.3.0",
"knex": "^0.21.1",
"nodegit": "^0.27.0",
"cross-fetch": "^3.0.6",
diff --git a/plugins/techdocs-backend/src/default-branch.ts b/plugins/techdocs-backend/src/default-branch.ts
index bbf40c17fc..d26732dbe6 100644
--- a/plugins/techdocs-backend/src/default-branch.ts
+++ b/plugins/techdocs-backend/src/default-branch.ts
@@ -61,6 +61,16 @@ function getGitlabApiUrl(url: string): URL {
);
}
+function getAzureApiUrl(url: string): URL {
+ const { protocol, resource, organization, owner, name } = parseGitUrl(url);
+ const apiRepoPath = '_apis/git/repositories';
+ const apiVersion = 'api-version=6.0';
+
+ return new URL(
+ `${protocol}://${resource}/${organization}/${owner}/${apiRepoPath}/${name}?${apiVersion}`,
+ );
+}
+
function getGithubRequestOptions(config: Config): RequestInit {
const headers: HeadersInit = {
Accept: 'application/vnd.github.v3.raw',
@@ -69,7 +79,7 @@ function getGithubRequestOptions(config: Config): RequestInit {
const token =
config.getOptionalString('catalog.processors.github.privateToken') ??
config.getOptionalString('catalog.processors.githubApi.privateToken') ??
- process.env.GITHUB_PRIVATE_TOKEN;
+ process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `token ${token}`;
@@ -88,7 +98,7 @@ function getGitlabRequestOptions(config: Config): RequestInit {
const token =
config.getOptionalString('catalog.processors.gitlab.privateToken') ??
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
- process.env.GITLAB_ACCESS_TOKEN;
+ process.env.GITLAB_TOKEN;
if (token) {
headers['PRIVATE-TOKEN'] = token;
@@ -99,6 +109,26 @@ function getGitlabRequestOptions(config: Config): RequestInit {
};
}
+function getAzureRequestOptions(config: Config): RequestInit {
+ const headers: HeadersInit = {};
+
+ const token =
+ config.getOptionalString('catalog.processors.azureApi.privateToken') ??
+ process.env.AZURE_TOKEN;
+
+ if (token !== '') {
+ headers.Authorization = `Basic ${Buffer.from(`:${token}`, 'utf8').toString(
+ 'base64',
+ )}`;
+ }
+
+ const requestOptions: RequestInit = {
+ headers,
+ };
+
+ return requestOptions;
+}
+
async function getGithubDefaultBranch(
repositoryUrl: string,
config: Config,
@@ -159,6 +189,42 @@ async function getGitlabDefaultBranch(
}
}
+async function getAzureDefaultBranch(
+ repositoryUrl: string,
+ config: Config,
+): Promise {
+ const path = getAzureApiUrl(repositoryUrl).toString();
+
+ const options = getAzureRequestOptions(config);
+
+ try {
+ const urlResponse = await fetch(path, options);
+ if (!urlResponse.ok) {
+ throw new Error(
+ `Failed to load url: ${urlResponse.status} ${urlResponse.statusText}. Make sure you have permission to repository: ${repositoryUrl}`,
+ );
+ }
+ const urlResult = await urlResponse.json();
+
+ const idResponse = await fetch(urlResult.url, options);
+ if (!idResponse.ok) {
+ throw new Error(
+ `Failed to load url: ${idResponse.status} ${idResponse.statusText}. Make sure you have permission to repository: ${urlResult.repository.url}`,
+ );
+ }
+ const idResult = await idResponse.json();
+ const name = idResult.defaultBranch;
+
+ if (!name) {
+ throw new Error('Not found Azure DevOps default branch');
+ }
+
+ return name;
+ } catch (error) {
+ throw new Error(`Failed to get Azure DevOps default branch: ${error}`);
+ }
+}
+
export const getDefaultBranch = async (
repositoryUrl: string,
): Promise => {
@@ -166,6 +232,7 @@ export const getDefaultBranch = async (
const typeMapping = [
{ url: /github*/g, type: 'github' },
{ url: /gitlab*/g, type: 'gitlab' },
+ { url: /azure*/g, type: 'azure/api' },
];
const type = typeMapping.filter(item => item.url.test(repositoryUrl))[0]
@@ -177,6 +244,8 @@ export const getDefaultBranch = async (
return await getGithubDefaultBranch(repositoryUrl, config);
case 'gitlab':
return await getGitlabDefaultBranch(repositoryUrl, config);
+ case 'azure/api':
+ return await getAzureDefaultBranch(repositoryUrl, config);
default:
throw new Error('Failed to get repository type');
diff --git a/plugins/techdocs-backend/src/helpers.ts b/plugins/techdocs-backend/src/helpers.ts
index d6a275f183..b2dcd1e8e8 100644
--- a/plugins/techdocs-backend/src/helpers.ts
+++ b/plugins/techdocs-backend/src/helpers.ts
@@ -76,6 +76,7 @@ export const getLocationForEntity = (
switch (type) {
case 'github':
case 'gitlab':
+ case 'azure/api':
return { type, target };
case 'dir':
if (path.isAbsolute(target)) return { type, target };
@@ -124,10 +125,12 @@ export const checkoutGitRepository = async (
const user =
process.env.GITHUB_PRIVATE_TOKEN_USER ||
process.env.GITLAB_PRIVATE_TOKEN_USER ||
+ process.env.AZURE_PRIVATE_TOKEN_USER ||
'';
const token =
- process.env.GITHUB_PRIVATE_TOKEN ||
+ process.env.GITHUB_TOKEN ||
process.env.GITLAB_PRIVATE_TOKEN_USER ||
+ process.env.AZURE_TOKEN ||
'';
if (fs.existsSync(repositoryTmpPath)) {
diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts
index ca0dbd45b4..998a4808cf 100644
--- a/plugins/techdocs-backend/src/service/router.ts
+++ b/plugins/techdocs-backend/src/service/router.ts
@@ -78,7 +78,7 @@ export async function createRouter({
}
});
- router.get('/metadata/entity/:kind/:namespace/:name', async (req, res) => {
+ router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => {
const baseUrl = config.getString('backend.baseUrl');
const { kind, namespace, name } = req.params;
@@ -101,7 +101,7 @@ export async function createRouter({
}
});
- router.get('/docs/:kind/:namespace/:name/*', async (req, res) => {
+ router.get('/docs/:namespace/:kind/:name/*', async (req, res) => {
const storageUrl = config.getString('techdocs.storageUrl');
const { kind, namespace, name } = req.params;
diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.test.ts
new file mode 100644
index 0000000000..39a3d5c7fa
--- /dev/null
+++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.test.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { getVoidLogger } from '@backstage/backend-common';
+import { AzurePreparer } from './azure';
+import { checkoutGitRepository } from '../../../helpers';
+
+function normalizePath(path: string) {
+ return path
+ .replace(/^[a-z]:/i, '')
+ .split('\\')
+ .join('/');
+}
+
+jest.mock('../../../helpers', () => ({
+ ...jest.requireActual<{}>('../../../helpers'),
+ checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'),
+}));
+
+const createMockEntity = (annotations = {}) => {
+ return {
+ apiVersion: 'version',
+ kind: 'TestKind',
+ metadata: {
+ name: 'test-component-name',
+ annotations: {
+ ...annotations,
+ },
+ },
+ };
+};
+
+const logger = getVoidLogger();
+
+describe('Azure DevOps preparer', () => {
+ it('should prepare temp docs path from Azure DevOps repo', async () => {
+ const preparer = new AzurePreparer(logger);
+
+ const mockEntity = createMockEntity({
+ 'backstage.io/techdocs-ref':
+ 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml',
+ });
+
+ const tempDocsPath = await preparer.prepare(mockEntity);
+ expect(checkoutGitRepository).toHaveBeenCalledTimes(1);
+ expect(normalizePath(tempDocsPath)).toEqual(
+ '/tmp/backstage-repo/org/name/branch/template.yaml',
+ );
+ });
+});
diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.ts
new file mode 100644
index 0000000000..18e6c3c755
--- /dev/null
+++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 path from 'path';
+import { Entity } from '@backstage/catalog-model';
+import { InputError } from '@backstage/backend-common';
+import { PreparerBase } from './types';
+import parseGitUrl from 'git-url-parse';
+import {
+ parseReferenceAnnotation,
+ checkoutGitRepository,
+} from '../../../helpers';
+
+import { Logger } from 'winston';
+
+export class AzurePreparer implements PreparerBase {
+ private readonly logger: Logger;
+
+ constructor(logger: Logger) {
+ this.logger = logger;
+ }
+
+ async prepare(entity: Entity): Promise {
+ const { type, target } = parseReferenceAnnotation(
+ 'backstage.io/techdocs-ref',
+ entity,
+ );
+
+ if (type !== 'azure/api') {
+ throw new InputError(`Wrong target type: ${type}, should be 'azure/api'`);
+ }
+
+ try {
+ const repoPath = await checkoutGitRepository(target, this.logger);
+ const parsedGitLocation = parseGitUrl(target);
+
+ return path.join(repoPath, parsedGitLocation.filepath);
+ } catch (error) {
+ this.logger.debug(`Repo checkout failed with error ${error.message}`);
+ throw error;
+ }
+ }
+}
diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts
index cad8a7effa..d76aee25bd 100644
--- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts
+++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts
@@ -42,7 +42,8 @@ export class DirectoryPreparer implements PreparerBase {
);
switch (type) {
case 'github':
- case 'gitlab': {
+ case 'gitlab':
+ case 'azure/api': {
const parsedGitLocation = parseGitUrl(target);
const repoLocation = await checkoutGitRepository(target, this.logger);
diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts
index 9e91958bd7..249854f7ee 100644
--- a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts
+++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts
@@ -16,5 +16,6 @@
export { DirectoryPreparer } from './dir';
export { GithubPreparer } from './github';
export { GitlabPreparer } from './gitlab';
+export { AzurePreparer } from './azure';
export { Preparers } from './preparers';
export type { PreparerBuilder, PreparerBase } from './types';
diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts
index 4cb1e1e006..1dd091e107 100644
--- a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts
+++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts
@@ -30,4 +30,4 @@ export type PreparerBuilder = {
get(entity: Entity): PreparerBase;
};
-export type RemoteProtocol = 'dir' | 'github' | 'gitlab' | 'file';
+export type RemoteProtocol = 'dir' | 'github' | 'gitlab' | 'file' | 'azure/api';
diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts
index 55e0a547e9..64edc5c294 100644
--- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts
+++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts
@@ -53,7 +53,7 @@ describe('local publisher', () => {
const resultDir = path.resolve(
__dirname,
- `../../../../static/docs/${mockEntity.kind}/default/${mockEntity.metadata.name}`,
+ `../../../../static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`,
);
expect(fs.existsSync(resultDir)).toBeTruthy();
diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts
index f328443965..6d655d4632 100644
--- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts
+++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts
@@ -42,8 +42,8 @@ export class LocalPublish implements PublisherBase {
const publishDir = resolvePackagePath(
'@backstage/plugin-techdocs-backend',
'static/docs',
- entity.kind,
entityNamespace,
+ entity.kind,
entity.metadata.name,
);
diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx
index d10c7a5932..544c2cdadd 100644
--- a/plugins/techdocs/src/EntityPageDocs.tsx
+++ b/plugins/techdocs/src/EntityPageDocs.tsx
@@ -23,7 +23,7 @@ export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts
index f7c52f8a7d..132976e280 100644
--- a/plugins/techdocs/src/api.test.ts
+++ b/plugins/techdocs/src/api.test.ts
@@ -28,7 +28,7 @@ describe('TechDocsStorageApi', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual(
- `${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`,
+ `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`,
);
});
@@ -36,7 +36,7 @@ describe('TechDocsStorageApi', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual(
- `${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`,
+ `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
);
});
});
diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts
index 87a0a56a01..31e4a10e4c 100644
--- a/plugins/techdocs/src/api.ts
+++ b/plugins/techdocs/src/api.ts
@@ -51,9 +51,7 @@ export class TechDocsApi implements TechDocs {
async getMetadata(metadataType: string, entityId: ParsedEntityId) {
const { kind, namespace, name } = entityId;
- const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${kind}/${
- namespace ? namespace : 'default'
- }/${name}`;
+ const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${namespace}/${kind}/${name}`;
const request = await fetch(`${requestUrl}`);
const res = await request.json();
@@ -72,9 +70,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
async getEntityDocs(entityId: ParsedEntityId, path: string) {
const { kind, namespace, name } = entityId;
- const url = `${this.apiOrigin}/docs/${kind}/${
- namespace ? namespace : 'default'
- }/${name}/${path}`;
+ const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`;
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
@@ -96,9 +92,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
return new URL(
oldBaseUrl,
- `${this.apiOrigin}/docs/${kind}/${
- namespace ? namespace : 'default'
- }/${name}/${path}`,
+ `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`,
).toString();
}
}
diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts
index b197bdfb76..ee2cab60fa 100644
--- a/plugins/techdocs/src/plugin.ts
+++ b/plugins/techdocs/src/plugin.ts
@@ -48,7 +48,7 @@ export const rootRouteRef = createRouteRef({
});
export const rootDocsRouteRef = createRouteRef({
- path: ':entityId/*',
+ path: ':namespace/:kind/:name/*',
title: 'Docs',
});
diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx
index 550b8df923..01299b8fe6 100644
--- a/plugins/techdocs/src/reader/components/Reader.tsx
+++ b/plugins/techdocs/src/reader/components/Reader.tsx
@@ -116,6 +116,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
return dom;
},
addLinkClickListener({
+ baseUrl: window.location.origin,
onClick: (_: MouseEvent, url: string) => {
const parsedUrl = new URL(url);
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx
index c478a52127..cbef46f6c1 100644
--- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx
@@ -84,9 +84,9 @@ export const TechDocsHome = () => {
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
- entityId: `${entity.kind}:${
- entity.metadata.namespace ?? ''
- }:${entity.metadata.name}`,
+ namespace: entity.metadata.namespace ?? 'default',
+ kind: entity.kind,
+ name: entity.metadata.name,
}),
)
}
diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx
index a70ce76191..3c4005762e 100644
--- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx
@@ -24,8 +24,7 @@ import { techdocsApiRef } from '../../api';
export const TechDocsPage = () => {
const [documentReady, setDocumentReady] = useState(false);
- const { entityId } = useParams();
- const [kind, namespace, name] = entityId.split(':');
+ const { namespace, kind, name } = useParams();
const techDocsApi = useApi(techdocsApiRef);
diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx
index 70388e81c2..0d8fe9cabb 100644
--- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx
@@ -54,7 +54,7 @@ describe(' ', () => {
),
);
expect(rendered.container.innerHTML).toContain('header');
- expect(rendered.getByText('test-site-name')).toBeDefined();
+ expect(rendered.getAllByText('test-site-name')).toHaveLength(2);
expect(rendered.getByText('test-site-desc')).toBeDefined();
});
});
diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx
index b01ace4994..c59cce2b11 100644
--- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx
@@ -48,12 +48,14 @@ export const TechDocsPageHeader = ({
spec: { owner, lifecycle },
} = entityMetadataValues || { spec: {} };
+ const componentLink = `/catalog/${kind}/${name}`;
+
const labels = (
<>
+
{name}
}
@@ -66,7 +68,11 @@ export const TechDocsPageHeader = ({
+
}
@@ -81,6 +87,8 @@ export const TechDocsPageHeader = ({
subtitle={
siteDescription && siteDescription !== 'None' ? siteDescription : ''
}
+ type={name}
+ typeLink={componentLink}
>
{labels}
diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts
index 8e942b8be1..775780fe4d 100644
--- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts
+++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts
@@ -14,23 +14,61 @@
* limitations under the License.
*/
-import { createTestShadowDom, FIXTURES } from '../../test-utils';
+import { createTestShadowDom } from '../../test-utils';
import { addLinkClickListener } from '.';
describe('addLinkClickListener', () => {
it('calls onClick when a link has been clicked', () => {
const fn = jest.fn();
- const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
- preTransformers: [],
- postTransformers: [
- addLinkClickListener({
- onClick: fn,
- }),
- ],
- });
+ const shadowDom = createTestShadowDom(
+ `
+
+
+
+ Link
+
+
+ `,
+ {
+ preTransformers: [],
+ postTransformers: [
+ addLinkClickListener({
+ baseUrl: 'http://localhost:3000',
+ onClick: fn,
+ }),
+ ],
+ },
+ );
shadowDom.querySelector('a')?.click();
expect(fn).toHaveBeenCalledTimes(1);
});
+
+ it('does not call onClick when a link links to another baseUrl', () => {
+ const fn = jest.fn();
+ const shadowDom = createTestShadowDom(
+ `
+
+
+
+ Link
+
+
+ `,
+ {
+ preTransformers: [],
+ postTransformers: [
+ addLinkClickListener({
+ baseUrl: 'http://localhost:3000',
+ onClick: fn,
+ }),
+ ],
+ },
+ );
+
+ shadowDom.querySelector('a')?.click();
+
+ expect(fn).toHaveBeenCalledTimes(0);
+ });
});
diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts
index 3566b403a2..b6a6509550 100644
--- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts
+++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts
@@ -17,10 +17,12 @@
import type { Transformer } from './index';
type AddLinkClickListenerOptions = {
+ baseUrl: string;
onClick: (e: MouseEvent, newUrl: string) => void;
};
export const addLinkClickListener = ({
+ baseUrl,
onClick,
}: AddLinkClickListenerOptions): Transformer => {
return dom => {
@@ -28,8 +30,9 @@ export const addLinkClickListener = ({
elem.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLAnchorElement;
const href = target?.getAttribute('href');
+
if (!href) return;
- if (!href.match(/^https?:\/\//i)) {
+ if (href.startsWith(baseUrl)) {
e.preventDefault();
onClick(e, target.getAttribute('href')!);
}
diff --git a/plugins/user-settings/.eslintrc.js b/plugins/user-settings/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/user-settings/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/user-settings/README.md b/plugins/user-settings/README.md
new file mode 100644
index 0000000000..db0b01681f
--- /dev/null
+++ b/plugins/user-settings/README.md
@@ -0,0 +1,65 @@
+# user-settings
+
+Welcome to the user-settings plugin!
+
+_This plugin was created through the Backstage CLI_
+
+## About the plugin
+
+This plugin provides two components, ` ` is intended to be used within the [``](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name.
+
+The second component is a settings page where the user can control different settings across the App.
+
+## Usage
+
+Add the item to the Sidebar:
+
+```ts
+import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
+
+
+
+
+
+ ;
+```
+
+Add the page to the App routing:
+
+```ts
+import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
+
+const AppRoutes = () => (
+
+ } />
+
+);
+```
+
+### Props
+
+**Auth Providers**
+
+By default, the plugin provides a list of configured authentication providers fetched from `app-config.yaml` and displayed in the "Authentication Providers" tab.
+
+If you want to supply your own custom list of Authentication Providers, use the `providerSettings` prop:
+
+```ts
+const MyAuthProviders = () => (
+
+
+ {someAction}
+
+);
+
+const AppRoutes = () => (
+
+ } />}
+ />
+
+);
+```
+
+> **Note that the list of providers expects to be rendered within a MUI [``](https://material-ui.com/components/lists/)**
diff --git a/plugins/user-settings/dev/index.tsx b/plugins/user-settings/dev/index.tsx
new file mode 100644
index 0000000000..264d6f801f
--- /dev/null
+++ b/plugins/user-settings/dev/index.tsx
@@ -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 { createDevApp } from '@backstage/dev-utils';
+import { plugin } from '../src/plugin';
+
+createDevApp().registerPlugin(plugin).render();
diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json
new file mode 100644
index 0000000000..29f86c774b
--- /dev/null
+++ b/plugins/user-settings/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@backstage/plugin-user-settings",
+ "version": "0.1.1-alpha.24",
+ "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.24",
+ "@backstage/theme": "^0.1.1-alpha.24",
+ "@material-ui/core": "^4.11.0",
+ "@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": "6.0.0-beta.0",
+ "react-use": "^15.3.3"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.24",
+ "@backstage/dev-utils": "^0.1.1-alpha.24",
+ "@backstage/test-utils": "^0.1.1-alpha.24",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^10.4.1",
+ "@testing-library/user-event": "^12.0.7",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^12.0.0",
+ "msw": "^0.20.5",
+ "node-fetch": "^2.6.1"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx b/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx
new file mode 100644
index 0000000000..4399b6bd3e
--- /dev/null
+++ b/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ ApiProvider,
+ ApiRegistry,
+ configApiRef,
+ ConfigReader,
+ googleAuthApiRef,
+} from '@backstage/core';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { AuthProviders } from './AuthProviders';
+
+const mockSignInHandler = jest.fn().mockReturnValue('');
+const mockGoogleAuth = {
+ sessionState$: () => ({
+ subscribe: () => ({
+ unsubscribe: () => null,
+ }),
+ }),
+ signIn: mockSignInHandler,
+};
+
+const createConfig = () =>
+ ConfigReader.fromConfigs([
+ {
+ context: '',
+ data: {
+ auth: {
+ providers: {
+ google: { development: {} },
+ },
+ },
+ },
+ },
+ ]);
+
+const config = createConfig();
+
+const apiRegistry = ApiRegistry.from([
+ [configApiRef, config],
+ [googleAuthApiRef, mockGoogleAuth],
+]);
+
+describe(' ', () => {
+ it('displays a provider and calls its sign-in handler on click', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(rendered.getByText('Google')).toBeInTheDocument();
+ expect(
+ rendered.getByText(googleAuthApiRef.description),
+ ).toBeInTheDocument();
+
+ const button = rendered.getByTitle('Sign in to Google');
+ fireEvent.click(button);
+ expect(mockSignInHandler).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx b/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx
new file mode 100644
index 0000000000..563bf44150
--- /dev/null
+++ b/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx
@@ -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 React from 'react';
+import { List } from '@material-ui/core';
+import { configApiRef, InfoCard, useApi } from '@backstage/core';
+import { EmptyProviders } from './EmptyProviders';
+import { DefaultProviderSettings } from './DefaultProviderSettings';
+
+type Props = {
+ providerSettings?: JSX.Element;
+};
+
+export const AuthProviders = ({ providerSettings }: Props) => {
+ const configApi = useApi(configApiRef);
+ const providersConfig = configApi.getOptionalConfig('auth.providers');
+ const configuredProviders = providersConfig?.keys() || [];
+ const providers = providerSettings ?? (
+
+ );
+
+ if (!providerSettings && !configuredProviders?.length) {
+ return ;
+ }
+
+ return (
+
+ {providers}
+
+ );
+};
diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx
new file mode 100644
index 0000000000..a1dab1e7d4
--- /dev/null
+++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx
@@ -0,0 +1,83 @@
+/*
+ * 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 {
+ githubAuthApiRef,
+ gitlabAuthApiRef,
+ googleAuthApiRef,
+ oauth2ApiRef,
+ oktaAuthApiRef,
+ microsoftAuthApiRef,
+} from '@backstage/core';
+import Star from '@material-ui/icons/Star';
+import React from 'react';
+import { ProviderSettingsItem } from './ProviderSettingsItem';
+
+type Props = {
+ configuredProviders: string[];
+};
+
+export const DefaultProviderSettings = ({ configuredProviders }: Props) => (
+ <>
+ {configuredProviders.includes('google') && (
+
+ )}
+ {configuredProviders.includes('microsoft') && (
+
+ )}
+ {configuredProviders.includes('github') && (
+
+ )}
+ {configuredProviders.includes('gitlab') && (
+
+ )}
+ {configuredProviders.includes('okta') && (
+
+ )}
+ {configuredProviders.includes('oauth2') && (
+
+ )}
+ >
+);
diff --git a/plugins/user-settings/src/components/AuthProviders/EmptyProviders.tsx b/plugins/user-settings/src/components/AuthProviders/EmptyProviders.tsx
new file mode 100644
index 0000000000..653f449e92
--- /dev/null
+++ b/plugins/user-settings/src/components/AuthProviders/EmptyProviders.tsx
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { CodeSnippet, EmptyState } from '@backstage/core';
+import { Button, Typography } from '@material-ui/core';
+
+const EXAMPLE = `auth:
+ providers:
+ google:
+ development:
+ clientId:
+ $env: AUTH_GOOGLE_CLIENT_ID
+ clientSecret:
+ $env: AUTH_GOOGLE_CLIENT_SECRET
+`;
+
+export const EmptyProviders = () => (
+
+
+ Open app-config.yaml and make the changes as highlighted
+ below:
+
+
+
+ Read More
+
+ >
+ }
+ />
+);
diff --git a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx
similarity index 65%
rename from packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx
rename to plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx
index 4db7ea0823..7213ee2b83 100644
--- a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx
+++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx
@@ -13,8 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-import React, { FC, useState, useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
+import {
+ ApiRef,
+ SessionApi,
+ useApi,
+ IconComponent,
+ SessionState,
+} from '@backstage/core';
import {
ListItem,
ListItemIcon,
@@ -24,25 +30,20 @@ import {
} from '@material-ui/core';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
import { ToggleButton } from '@material-ui/lab';
-import {
- ApiRef,
- SessionApi,
- useApi,
- IconComponent,
- SessionState,
-} from '@backstage/core-api';
-type OAuthProviderSidebarProps = {
+type Props = {
title: string;
+ description: string;
icon: IconComponent;
apiRef: ApiRef;
};
-export const ProviderSettingsItem: FC = ({
+export const ProviderSettingsItem = ({
title,
+ description,
icon: Icon,
apiRef,
-}) => {
+}: Props) => {
const api = useApi(apiRef);
const [signedIn, setSignedIn] = useState(false);
@@ -68,22 +69,30 @@ export const ProviderSettingsItem: FC = ({
-
-
- (signedIn ? api.signOut() : api.signIn())}
- >
-
-
+
+ {description}
-
+ }
+ secondaryTypographyProps={{ noWrap: true, style: { width: '80%' } }}
+ />
+
+
+ (signedIn ? api.signOut() : api.signIn())}
+ >
+
+
+
);
diff --git a/plugins/user-settings/src/components/AuthProviders/index.ts b/plugins/user-settings/src/components/AuthProviders/index.ts
new file mode 100644
index 0000000000..2f6a2b5ab1
--- /dev/null
+++ b/plugins/user-settings/src/components/AuthProviders/index.ts
@@ -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 { AuthProviders } from './AuthProviders';
+export { DefaultProviderSettings } from './DefaultProviderSettings';
diff --git a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx
new file mode 100644
index 0000000000..5df4df4450
--- /dev/null
+++ b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { CodeSnippet, EmptyState } from '@backstage/core';
+import { Button, Typography } from '@material-ui/core';
+
+const EXAMPLE = `import { createPlugin } from '@backstage/core';
+
+export default createPlugin({
+ id: 'welcome',
+ register({ router, featureFlags }) {
+ featureFlags.register('enable-example-feature');
+ },
+});
+`;
+
+export const EmptyFlags = () => (
+
+
+ An example how how to add a feature flags is highlighted below:
+
+
+
+ Read More
+
+ >
+ }
+ />
+);
diff --git a/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx
new file mode 100644
index 0000000000..71f59c52b8
--- /dev/null
+++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx
@@ -0,0 +1,82 @@
+/*
+ * 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, { useCallback, useState } from 'react';
+import {
+ FeatureFlagName,
+ featureFlagsApiRef,
+ FeatureFlagsRegistryItem,
+ FeatureFlagState,
+ InfoCard,
+ useApi,
+} from '@backstage/core';
+import { List } from '@material-ui/core';
+import { EmptyFlags } from './EmptyFlags';
+import { FlagItem } from './FeatureFlagsItem';
+
+export const FeatureFlags = () => {
+ const featureFlagsApi = useApi(featureFlagsApiRef);
+ const featureFlags = featureFlagsApi.getRegisteredFlags();
+ const initialFlagState = featureFlags.reduce(
+ (result, featureFlag: FeatureFlagsRegistryItem) => {
+ const state = featureFlagsApi.getFlags().get(featureFlag.name);
+
+ result[featureFlag.name] = state;
+ return result;
+ },
+ {} as Record,
+ );
+
+ const [state, setState] = useState>(
+ initialFlagState,
+ );
+
+ const toggleFlag = useCallback(
+ (flagName: FeatureFlagName) => {
+ const newState = featureFlagsApi.getFlags().toggle(flagName);
+
+ setState(prevState => ({
+ ...prevState,
+ [flagName]: newState,
+ }));
+ featureFlagsApi.getFlags().save();
+ },
+ [featureFlagsApi],
+ );
+
+ if (!featureFlags.length) {
+ return ;
+ }
+
+ return (
+
+
+ {featureFlags.map(featureFlag => {
+ const enabled = Boolean(state[featureFlag.name]);
+
+ return (
+
+ );
+ })}
+
+
+ );
+};
diff --git a/packages/core/src/layout/Sidebar/Settings/FeatureFlagsItem.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx
similarity index 51%
rename from packages/core/src/layout/Sidebar/Settings/FeatureFlagsItem.tsx
rename to plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx
index 3e40ce69cc..92065f2ed6 100644
--- a/packages/core/src/layout/Sidebar/Settings/FeatureFlagsItem.tsx
+++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx
@@ -15,11 +15,6 @@
*/
import React from 'react';
-import {
- FeatureFlagName,
- useApi,
- featureFlagsApiRef,
-} from '@backstage/core-api';
import {
ListItem,
ListItemSecondaryAction,
@@ -28,46 +23,31 @@ import {
} from '@material-ui/core';
import CheckIcon from '@material-ui/icons/CheckCircle';
import { ToggleButton } from '@material-ui/lab';
-
-export type Item = {
- name: FeatureFlagName;
- pluginId: string;
-};
+import { FeatureFlagsRegistryItem } from '@backstage/core';
type Props = {
- featureFlag: Item;
+ flag: FeatureFlagsRegistryItem;
+ enabled: boolean;
+ toggleHandler: Function;
};
-export const FlagItem = ({ featureFlag }: Props) => {
- const api = useApi(featureFlagsApiRef);
-
- const [enabled, setEnabled] = React.useState(
- Boolean(api.getFlags().get(featureFlag.name)),
- );
-
- const toggleFlag = () => {
- const newState = api.getFlags().toggle(featureFlag.name);
- setEnabled(Boolean(newState));
- };
-
- return (
-
-
-
+export const FlagItem = ({ flag, enabled, toggleHandler }: Props) => (
+
+
+
+
toggleHandler(flag.name)}
>
-
-
-
+
-
-
- );
-};
+
+
+
+);
diff --git a/plugins/user-settings/src/components/FeatureFlags/index.ts b/plugins/user-settings/src/components/FeatureFlags/index.ts
new file mode 100644
index 0000000000..37c9fd1fd2
--- /dev/null
+++ b/plugins/user-settings/src/components/FeatureFlags/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { FeatureFlags } from './FeatureFlags';
diff --git a/plugins/user-settings/src/components/General/General.tsx b/plugins/user-settings/src/components/General/General.tsx
new file mode 100644
index 0000000000..00ac17dfe1
--- /dev/null
+++ b/plugins/user-settings/src/components/General/General.tsx
@@ -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 { InfoCard } from '@backstage/core';
+import { Grid, List } from '@material-ui/core';
+import React from 'react';
+import { PinButton } from './PinButton';
+import { Profile } from './Profile';
+import { ThemeToggle } from './ThemeToggle';
+
+export const General = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/plugins/user-settings/src/components/General/PinButton.test.tsx b/plugins/user-settings/src/components/General/PinButton.test.tsx
new file mode 100644
index 0000000000..76af8ce8d4
--- /dev/null
+++ b/plugins/user-settings/src/components/General/PinButton.test.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { SidebarPinStateContext } from '@backstage/core';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { PinButton } from './PinButton';
+
+describe(' ', () => {
+ it('toggles the pin sidebar button', async () => {
+ const mockToggleFn = jest.fn();
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(rendered.getByText('Pin Sidebar')).toBeInTheDocument();
+
+ const pinButton = rendered.getByTitle('Pin Sidebar');
+ fireEvent.click(pinButton);
+ expect(mockToggleFn).toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/layout/Sidebar/Settings/PinButton.tsx b/plugins/user-settings/src/components/General/PinButton.tsx
similarity index 65%
rename from packages/core/src/layout/Sidebar/Settings/PinButton.tsx
rename to plugins/user-settings/src/components/General/PinButton.tsx
index 2727313ede..e36fc365f8 100644
--- a/packages/core/src/layout/Sidebar/Settings/PinButton.tsx
+++ b/plugins/user-settings/src/components/General/PinButton.tsx
@@ -24,23 +24,18 @@ import {
import LockIcon from '@material-ui/icons/Lock';
import LockOpenIcon from '@material-ui/icons/LockOpen';
import { ToggleButton } from '@material-ui/lab';
-import { SidebarPinStateContext } from '../Page';
+import { SidebarPinStateContext } from '@backstage/core';
-export const SidebarPinButton = () => {
+type PinIconProps = { isPinned: boolean };
+
+const PinIcon = ({ isPinned }: PinIconProps) =>
+ isPinned ? : ;
+
+export const PinButton = () => {
const { isPinned, toggleSidebarPinState } = useContext(
SidebarPinStateContext,
);
- const PinIcon = () => (
-
- {isPinned ? : }
-
- );
-
return (
{
secondary="Prevent the sidebar from collapsing"
/>
- {
- toggleSidebarPinState();
- }}
+
-
-
+ {
+ toggleSidebarPinState();
+ }}
+ >
+
+
+
);
diff --git a/plugins/user-settings/src/components/General/Profile.tsx b/plugins/user-settings/src/components/General/Profile.tsx
new file mode 100644
index 0000000000..b0231d95b6
--- /dev/null
+++ b/plugins/user-settings/src/components/General/Profile.tsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { InfoCard } from '@backstage/core';
+import { Grid, Typography } from '@material-ui/core';
+import React from 'react';
+import { SignInAvatar } from './SignInAvatar';
+import { UserSettingsMenu } from './UserSettingsMenu';
+import { useUserProfile } from '../useUserProfileInfo';
+
+export const Profile = () => {
+ const { profile, displayName } = useUserProfile();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {displayName}
+
+
+ {profile.email}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/core/src/layout/Sidebar/Settings/SignInAvatar.tsx b/plugins/user-settings/src/components/General/SignInAvatar.tsx
similarity index 72%
rename from packages/core/src/layout/Sidebar/Settings/SignInAvatar.tsx
rename to plugins/user-settings/src/components/General/SignInAvatar.tsx
index f0430edfcf..8a5bbd75bf 100644
--- a/packages/core/src/layout/Sidebar/Settings/SignInAvatar.tsx
+++ b/plugins/user-settings/src/components/General/SignInAvatar.tsx
@@ -17,26 +17,24 @@
import React from 'react';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles, Avatar } from '@material-ui/core';
-import { useUserProfile } from './useUserProfileInfo';
-import { sidebarConfig } from '../config';
+import { useUserProfile } from '../useUserProfileInfo';
+import { sidebarConfig } from '@backstage/core';
-const useStyles = makeStyles({
+const useStyles = makeStyles(theme => ({
avatar: {
width: ({ size }) => size,
height: ({ size }) => size,
+ fontSize: ({ size }) => size * 0.7,
+ border: `1px solid ${theme.palette.textSubtle}`,
},
-});
+}));
type Props = { size?: number };
export const SignInAvatar = ({ size }: Props) => {
const { iconSize } = sidebarConfig;
const classes = useStyles(size ? { size } : { size: iconSize });
- const { profile, displayName } = useUserProfile();
+ const { profile } = useUserProfile();
- return (
-
- {displayName[0]}
-
- );
+ return ;
};
diff --git a/plugins/user-settings/src/components/General/ThemeToggle.test.tsx b/plugins/user-settings/src/components/General/ThemeToggle.test.tsx
new file mode 100644
index 0000000000..79d9210826
--- /dev/null
+++ b/plugins/user-settings/src/components/General/ThemeToggle.test.tsx
@@ -0,0 +1,58 @@
+/*
+ * 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 {
+ ApiProvider,
+ ApiRegistry,
+ appThemeApiRef,
+ AppThemeSelector,
+} from '@backstage/core';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { lightTheme } from '@backstage/theme';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { ThemeToggle } from './ThemeToggle';
+
+const mockTheme = {
+ id: 'light',
+ title: 'Mock Theme',
+ variant: 'light' as 'light', // wut?
+ theme: lightTheme,
+};
+
+const apiRegistry = ApiRegistry.from([
+ [appThemeApiRef, AppThemeSelector.createWithStorage([mockTheme])],
+]);
+
+describe(' ', () => {
+ it('toggles the theme select button', async () => {
+ const themeApi = apiRegistry.get(appThemeApiRef);
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(rendered.getByText('Theme')).toBeInTheDocument();
+
+ const themeButton = rendered.getByTitle('Select Mock Theme');
+ expect(themeApi?.getActiveThemeId()).toBe(undefined);
+ fireEvent.click(themeButton);
+ expect(themeApi?.getActiveThemeId()).toBe('light');
+ });
+});
diff --git a/packages/core/src/layout/Sidebar/Settings/ThemeToggle.tsx b/plugins/user-settings/src/components/General/ThemeToggle.tsx
similarity index 53%
rename from packages/core/src/layout/Sidebar/Settings/ThemeToggle.tsx
rename to plugins/user-settings/src/components/General/ThemeToggle.tsx
index a5e703089c..111bd838a8 100644
--- a/packages/core/src/layout/Sidebar/Settings/ThemeToggle.tsx
+++ b/plugins/user-settings/src/components/General/ThemeToggle.tsx
@@ -14,12 +14,10 @@
* limitations under the License.
*/
-import React from 'react';
+import React, { cloneElement } from 'react';
import { useObservable } from 'react-use';
-import LightIcon from '@material-ui/icons/WbSunny';
-import DarkIcon from '@material-ui/icons/Brightness2';
import AutoIcon from '@material-ui/icons/BrightnessAuto';
-import { appThemeApiRef, useApi } from '@backstage/core-api';
+import { appThemeApiRef, useApi } from '@backstage/core';
import ToggleButton from '@material-ui/lab/ToggleButton';
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup';
import {
@@ -29,7 +27,43 @@ import {
Tooltip,
} from '@material-ui/core';
-export const SidebarThemeToggle = () => {
+type ThemeIconProps = {
+ id: string;
+ activeId: string | undefined;
+ icon: JSX.Element | undefined;
+};
+
+const ThemeIcon = ({ id, activeId, icon }: ThemeIconProps) =>
+ icon ? (
+ cloneElement(icon, {
+ color: activeId === id ? 'primary' : undefined,
+ })
+ ) : (
+
+ );
+
+type TooltipToggleButtonProps = {
+ children: JSX.Element;
+ title: string;
+ value: string;
+};
+
+// ToggleButtonGroup uses React.children.map instead of context
+// so wrapping with Tooltip breaks ToggleButton functionality.
+const TooltipToggleButton = ({
+ children,
+ title,
+ value,
+ ...props
+}: TooltipToggleButtonProps) => (
+
+
+ {children}
+
+
+);
+
+export const ThemeToggle = () => {
const appThemeApi = useApi(appThemeApiRef);
const themeId = useObservable(
appThemeApi.activeThemeId$(),
@@ -37,11 +71,6 @@ export const SidebarThemeToggle = () => {
);
const themeIds = appThemeApi.getInstalledThemes();
- // TODO(marcuseide): can these be put on the theme itself?
- const themeIcons = {
- dark: ,
- light: ,
- };
const handleSetTheme = (
_event: React.MouseEvent,
@@ -64,22 +93,24 @@ export const SidebarThemeToggle = () => {
value={themeId ?? 'auto'}
onChange={handleSetTheme}
>
- {themeIds.map(theme => (
-
- {
+ const themeIcon = themeIds.find(t => t.id === theme.id)?.icon;
+
+ return (
+
- {themeIcons[theme.variant]}
-
+
+
+ );
+ })}
+
+
+
- ))}
-
-
-
-
-
+
diff --git a/plugins/user-settings/src/components/General/UserSettingsMenu.test.tsx b/plugins/user-settings/src/components/General/UserSettingsMenu.test.tsx
new file mode 100644
index 0000000000..c86d702e30
--- /dev/null
+++ b/plugins/user-settings/src/components/General/UserSettingsMenu.test.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { UserSettingsMenu } from './UserSettingsMenu';
+
+describe(' ', () => {
+ it('displays a menu button with a sign-out option', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+
+ const menuButton = rendered.getByLabelText('more');
+ fireEvent.click(menuButton);
+
+ expect(rendered.getByText('Sign Out')).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx b/plugins/user-settings/src/components/General/UserSettingsMenu.tsx
similarity index 93%
rename from packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx
rename to plugins/user-settings/src/components/General/UserSettingsMenu.tsx
index 151ddb6e75..19c3ee4e2d 100644
--- a/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx
+++ b/plugins/user-settings/src/components/General/UserSettingsMenu.tsx
@@ -15,7 +15,7 @@
*/
import React from 'react';
-import { identityApiRef, useApi } from '@backstage/core-api';
+import { identityApiRef, useApi } from '@backstage/core';
import { IconButton, ListItemIcon, Menu, MenuItem } from '@material-ui/core';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import MoreVertIcon from '@material-ui/icons/MoreVert';
@@ -39,7 +39,7 @@ export const UserSettingsMenu = () => {
return (
<>
-
+
diff --git a/plugins/user-settings/src/components/General/index.ts b/plugins/user-settings/src/components/General/index.ts
new file mode 100644
index 0000000000..2015d345fe
--- /dev/null
+++ b/plugins/user-settings/src/components/General/index.ts
@@ -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 { General } from './General';
+export { SignInAvatar } from './SignInAvatar';
diff --git a/packages/core/src/layout/Sidebar/Settings/FeatureFlagsList.tsx b/plugins/user-settings/src/components/Settings.tsx
similarity index 57%
rename from packages/core/src/layout/Sidebar/Settings/FeatureFlagsList.tsx
rename to plugins/user-settings/src/components/Settings.tsx
index 5687446511..f754f1e7c3 100644
--- a/packages/core/src/layout/Sidebar/Settings/FeatureFlagsList.tsx
+++ b/plugins/user-settings/src/components/Settings.tsx
@@ -15,18 +15,20 @@
*/
import React from 'react';
-import List from '@material-ui/core/List';
-import ListSubheader from '@material-ui/core/ListSubheader';
-import { FlagItem, Item } from './FeatureFlagsItem';
+import { SidebarItem } from '@backstage/core';
+import { SignInAvatar } from './General';
+import { useUserProfile } from './useUserProfileInfo';
+import { settingsRouteRef } from '../plugin';
-type Props = {
- featureFlags: Item[];
+export const Settings = () => {
+ const { displayName } = useUserProfile();
+ const SidebarAvatar = () => ;
+
+ return (
+
+ );
};
-
-export const FeatureFlagsList = ({ featureFlags }: Props) => (
- Feature Flags}>
- {featureFlags.map(featureFlag => (
-
- ))}
-
-);
diff --git a/plugins/user-settings/src/components/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage.tsx
new file mode 100644
index 0000000000..86096e9a1f
--- /dev/null
+++ b/plugins/user-settings/src/components/SettingsPage.tsx
@@ -0,0 +1,52 @@
+/*
+ * 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, { useState } from 'react';
+import { Content, Header, HeaderTabs, Page, pageTheme } from '@backstage/core';
+import { General } from './General';
+import { AuthProviders } from './AuthProviders';
+import { FeatureFlags } from './FeatureFlags';
+
+type Props = {
+ providerSettings?: JSX.Element;
+};
+
+export const SettingsPage = ({ providerSettings }: Props) => {
+ const [activeTab, setActiveTab] = useState(0);
+ const onTabChange = (index: number) => {
+ setActiveTab(index);
+ };
+
+ const tabs = [
+ { id: 'general', label: 'General' },
+ { id: 'auth-providers', label: 'Authentication Providers' },
+ { id: 'feature-flags', label: 'Feature Flags' },
+ ];
+
+ const content = [
+ ,
+ ,
+ ,
+ ];
+
+ return (
+
+
+
+ {content[activeTab]}
+
+ );
+};
diff --git a/plugins/user-settings/src/components/SidebarWithSettings.stories.tsx b/plugins/user-settings/src/components/SidebarWithSettings.stories.tsx
new file mode 100644
index 0000000000..fd3d6ce0f0
--- /dev/null
+++ b/plugins/user-settings/src/components/SidebarWithSettings.stories.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ ApiProvider,
+ ApiRegistry,
+ appThemeApiRef,
+ AppThemeSelector,
+ configApiRef,
+ ConfigReader,
+ FeatureFlags,
+ featureFlagsApiRef,
+ Sidebar,
+ SidebarDivider,
+ SidebarSpace,
+} from '@backstage/core';
+import { MemoryRouter } from 'react-router';
+import { Settings } from './Settings';
+import { SettingsPage } from './SettingsPage';
+
+export default {
+ title: 'Plugins/user-settings/Settings',
+ component: Settings,
+ decorators: [
+ (storyFn: () => JSX.Element) => (
+ {storyFn()}
+ ),
+ ],
+};
+
+export const SidebarItem = () => (
+
+
+
+
+
+);
+
+const createConfig = () =>
+ ConfigReader.fromConfigs([
+ {
+ context: '',
+ data: {
+ auth: {
+ providers: {},
+ },
+ },
+ },
+ ]);
+
+const config = createConfig();
+
+const apis = ApiRegistry.from([
+ [configApiRef, config],
+ [featureFlagsApiRef, new FeatureFlags()],
+ [appThemeApiRef, AppThemeSelector.createWithStorage([])],
+]);
+
+export const TheSettingsPage = () => (
+
+);
diff --git a/packages/core/src/layout/Sidebar/Settings/useUserProfileInfo.ts b/plugins/user-settings/src/components/useUserProfileInfo.ts
similarity index 93%
rename from packages/core/src/layout/Sidebar/Settings/useUserProfileInfo.ts
rename to plugins/user-settings/src/components/useUserProfileInfo.ts
index 60dae294a5..428719160d 100644
--- a/packages/core/src/layout/Sidebar/Settings/useUserProfileInfo.ts
+++ b/plugins/user-settings/src/components/useUserProfileInfo.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { useApi, identityApiRef } from '@backstage/core-api';
+import { useApi, identityApiRef } from '@backstage/core';
export const useUserProfile = () => {
const identityApi = useApi(identityApiRef);
diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts
new file mode 100644
index 0000000000..113930902a
--- /dev/null
+++ b/plugins/user-settings/src/index.ts
@@ -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 { plugin } from './plugin';
+export { Settings } from './components/Settings';
+export { SettingsPage as Router } from './components/SettingsPage';
diff --git a/plugins/user-settings/src/plugin.test.ts b/plugins/user-settings/src/plugin.test.ts
new file mode 100644
index 0000000000..810a2cda12
--- /dev/null
+++ b/plugins/user-settings/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * 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('user-settings', () => {
+ it('should export plugin', () => {
+ expect(plugin).toBeDefined();
+ });
+});
diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts
new file mode 100644
index 0000000000..2f896c8197
--- /dev/null
+++ b/plugins/user-settings/src/plugin.ts
@@ -0,0 +1,25 @@
+/*
+ * 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';
+
+export const settingsRouteRef = createRouteRef({
+ path: '/settings',
+ title: 'Settings',
+});
+
+export const plugin = createPlugin({
+ id: 'user-settings',
+});
diff --git a/plugins/user-settings/src/setupTests.ts b/plugins/user-settings/src/setupTests.ts
new file mode 100644
index 0000000000..0bfa67b49a
--- /dev/null
+++ b/plugins/user-settings/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@testing-library/jest-dom';
diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx
index 3ceb0af58d..1afdb53ad4 100644
--- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx
+++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx
@@ -69,7 +69,10 @@ const WelcomePage = () => {
the project and we have already begun work on various aspects
of these phases. The best way to keep track of the progress is
through the
-
+
Milestones
.
@@ -113,7 +116,10 @@ const WelcomePage = () => {
We suggest you either check out the documentation for{' '}
-
+
creating a plugin
{' '}
or have a look in the code for the{' '}
@@ -121,7 +127,10 @@ const WelcomePage = () => {
existing plugins
{' '}
in the directory{' '}
-
+
plugins/
.
@@ -135,7 +144,10 @@ const WelcomePage = () => {
backstage.io
-
+
Create a plugin
diff --git a/yarn.lock b/yarn.lock
index 0f81798230..c935ab0c5a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1194,7 +1194,7 @@
core-js-pure "^3.0.0"
regenerator-runtime "^0.13.4"
-"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.11.2"
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
@@ -4520,6 +4520,14 @@
"@babel/runtime" "^7.5.4"
"@types/testing-library__react-hooks" "^3.3.0"
+"@testing-library/react-hooks@^3.4.2":
+ version "3.4.2"
+ resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz#8deb94f7684e0d896edd84a4c90e5b79a0810bc2"
+ integrity sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A==
+ dependencies:
+ "@babel/runtime" "^7.5.4"
+ "@types/testing-library__react-hooks" "^3.4.0"
+
"@testing-library/react@^10.4.1":
version "10.4.3"
resolved "https://registry.npmjs.org/@testing-library/react/-/react-10.4.3.tgz#c6f356688cffc51f6b35385583d664bb11a161f4"
@@ -5193,22 +5201,12 @@
resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c"
integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA==
-"@types/node@^10.1.0":
- version "10.17.28"
- resolved "https://registry.npmjs.org/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9"
- integrity sha512-dzjES1Egb4c1a89C7lKwQh8pwjYmlOAG9dW1pBgxEk57tMrLnssOfEthz8kdkNaBd7lIqQx7APm5+mZ619IiCQ==
-
-"@types/node@^10.12.0":
+"@types/node@^10.1.0", "@types/node@^10.12.0":
version "10.17.35"
resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56"
integrity sha512-gXx7jAWpMddu0f7a+L+txMplp3FnHl53OhQIF9puXKq3hDGY/GjH+MF04oWnV/adPSCrbtHumDCFwzq2VhltWA==
-"@types/node@^12.0.0":
- version "12.12.53"
- resolved "https://registry.npmjs.org/@types/node/-/node-12.12.53.tgz#be0d375933c3d15ef2380dafb3b0350ea7021129"
- integrity sha512-51MYTDTyCziHb70wtGNFRwB4l+5JNvdqzFSkbDvpbftEgVUBEE+T5f7pROhWMp/fxp07oNIEQZd5bbfAH22ohQ==
-
-"@types/node@^12.7.1":
+"@types/node@^12.0.0", "@types/node@^12.7.1":
version "12.12.58"
resolved "https://registry.npmjs.org/@types/node/-/node-12.12.58.tgz#46dae9b2b9ee5992818c8f7cee01ff4ce03ab44c"
integrity sha512-Be46CNIHWAagEfINOjmriSxuv7IVcqbGe+sDSg2SYCEz/0CRBy7LRASGfRbD8KZkqoePU73Wsx3UvOSFcq/9hA==
@@ -5328,6 +5326,11 @@
resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a"
integrity sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ==
+"@types/raf@^3.4.0":
+ version "3.4.0"
+ resolved "https://registry.npmjs.org/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2"
+ integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==
+
"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
@@ -5632,6 +5635,13 @@
dependencies:
"@types/react-test-renderer" "*"
+"@types/testing-library__react-hooks@^3.4.0":
+ version "3.4.1"
+ resolved "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz#b8d7311c6c1f7db3103e94095fe901f8fef6e433"
+ integrity sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==
+ dependencies:
+ "@types/react-test-renderer" "*"
+
"@types/through@*":
version "0.0.30"
resolved "https://registry.npmjs.org/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
@@ -5694,9 +5704,9 @@
source-map "^0.6.1"
"@types/webpack@*", "@types/webpack@^4.41.7", "@types/webpack@^4.41.8":
- version "4.41.21"
- resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz#cc685b332c33f153bb2f5fc1fa3ac8adeb592dee"
- integrity sha512-2j9WVnNrr/8PLAB5csW44xzQSJwS26aOnICsP3pSGCEdsu6KYtfQ6QJsVUKHWRnm1bL7HziJsfh5fHqth87yKA==
+ version "4.41.22"
+ resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.22.tgz#ff9758a17c6bd499e459b91e78539848c32d0731"
+ integrity sha512-JQDJK6pj8OMV9gWOnN1dcLCyU9Hzs6lux0wBO4lr1+gyEhIBR9U3FMrz12t2GPkg110XAxEAw2WHF6g7nZIbRQ==
dependencies:
"@types/anymatch" "*"
"@types/node" "*"
@@ -5999,11 +6009,6 @@ JSONStream@^1.0.4, JSONStream@^1.3.4:
jsonparse "^1.2.0"
through ">=2.2.7 <3"
-abab@^1.0.0:
- version "1.0.4"
- resolved "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
- integrity sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4=
-
abab@^2.0.0, abab@^2.0.3:
version "2.0.3"
resolved "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a"
@@ -6027,13 +6032,6 @@ accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
mime-types "~2.1.24"
negotiator "0.6.2"
-acorn-globals@^1.0.4:
- version "1.0.9"
- resolved "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz#55bb5e98691507b74579d0513413217c380c54cf"
- integrity sha1-VbtemGkVB7dFedBRNBMhfDgMVM8=
- dependencies:
- acorn "^2.1.0"
-
acorn-globals@^4.1.0:
version "4.3.4"
resolved "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7"
@@ -6065,11 +6063,6 @@ acorn-walk@^7.1.1:
resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e"
integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==
-acorn@^2.1.0, acorn@^2.4.0:
- version "2.7.0"
- resolved "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7"
- integrity sha1-q259nYhqrKiwhbwzEreaGYQz8Oc=
-
acorn@^5.5.3:
version "5.7.4"
resolved "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
@@ -6852,7 +6845,7 @@ aws4@^1.8.0:
resolved "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
-axios@^0.19.0, axios@^0.19.2:
+axios@^0.19.2:
version "0.19.2"
resolved "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27"
integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==
@@ -7288,10 +7281,10 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
-base64-arraybuffer@^0.1.5:
- version "0.1.5"
- resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
- integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+base64-arraybuffer@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
+ integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==
base64-js@^1.0.2, base64-js@^1.2.0:
version "1.3.1"
@@ -7980,15 +7973,17 @@ canvas@^2.6.1:
node-pre-gyp "^0.11.0"
simple-get "^3.0.3"
-canvg@1.5.3:
- version "1.5.3"
- resolved "https://registry.npmjs.org/canvg/-/canvg-1.5.3.tgz#aad17915f33368bf8eb80b25d129e3ae922ddc5f"
- integrity sha512-7Gn2IuQzvUQWPIuZuFHrzsTM0gkPz2RRT9OcbdmA03jeKk8kltrD8gqUzNX15ghY/4PV5bbe5lmD6yDLDY6Ybg==
+canvg@^3.0.6:
+ version "3.0.6"
+ resolved "https://registry.npmjs.org/canvg/-/canvg-3.0.6.tgz#4f82a34acc433daa06c494fc255420cbbb05f903"
+ integrity sha512-eFUy8R/4DgocR93LF8lr+YUxW4PYblUe/Q1gz2osk/cI5n8AsYdassvln0D9QPhLXQ6Lx7l8hwtT8FLvOn2Ihg==
dependencies:
- jsdom "^8.1.0"
+ "@babel/runtime" "^7.6.3"
+ "@types/raf" "^3.4.0"
+ core-js "3"
+ raf "^3.4.1"
rgbcolor "^1.0.1"
- stackblur-canvas "^1.4.1"
- xmldom "^0.1.22"
+ stackblur-canvas "^2.0.0"
capture-exit@^2.0.0:
version "2.0.0"
@@ -8143,11 +8138,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
safe-buffer "^5.0.1"
circleci-api@^4.0.0:
- version "4.0.0"
- resolved "https://registry.npmjs.org/circleci-api/-/circleci-api-4.0.0.tgz#d773fe68f4a59e1968881269883a23b0805b3546"
- integrity sha512-D/THFyhOv6THSkYXJhrOLIOmV7fmyDqgs1+pBFMAqDR+ywXszxa2Dqx1Zw+YD3O2zD2y5LQOifCDT96VidRG7Q==
+ version "4.1.3"
+ resolved "https://registry.npmjs.org/circleci-api/-/circleci-api-4.1.3.tgz#cafd80bc1905c6064724636741b4b5b231ed34d0"
+ integrity sha512-Km6H8LB3nNbFUjErhI+sNQrcHcU7dCBnQEi4ckBtQMkCXh+2gDvaYchfkeuIaA1hkakQT5sLmdPbYzhKbvsOMg==
dependencies:
- axios "^0.19.0"
+ axios "^0.20.0"
class-utils@^0.3.5:
version "0.3.6"
@@ -8866,16 +8861,16 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1:
resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a"
integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw==
+core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5:
+ version "3.6.5"
+ resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
+ integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
+
core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.11, core-js@^2.6.5:
version "2.6.11"
resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
-core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.5:
- version "3.6.5"
- resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
- integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
-
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -9062,12 +9057,12 @@ css-in-js-utils@^2.0.0:
hyphenate-style-name "^1.0.2"
isobject "^3.0.1"
-css-line-break@1.0.1:
- version "1.0.1"
- resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-1.0.1.tgz#19f2063a33e95fb2831b86446c0b80c188af450a"
- integrity sha1-GfIGOjPpX7KDG4ZEbAuAwYivRQo=
+css-line-break@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-1.1.1.tgz#d5e9bdd297840099eb0503c7310fd34927a026ef"
+ integrity sha512-1feNVaM4Fyzdj4mKPIQNL2n70MmuYzAXZ1aytlROFX1JsOo070OsugwGjj7nl6jnDJWHDM8zRZswkmeYVWZJQA==
dependencies:
- base64-arraybuffer "^0.1.5"
+ base64-arraybuffer "^0.2.0"
css-loader@^3.5.3:
version "3.6.0"
@@ -9268,7 +9263,7 @@ csso@^4.0.2:
dependencies:
css-tree "1.0.0-alpha.37"
-cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0", "cssom@>= 0.3.2 < 0.4.0", cssom@~0.3.6:
+cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0", cssom@~0.3.6:
version "0.3.8"
resolved "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==
@@ -9278,13 +9273,6 @@ cssom@^0.4.4:
resolved "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10"
integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==
-"cssstyle@>= 0.2.34 < 0.3.0":
- version "0.2.37"
- resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz#541097234cb2513c83ceed3acddc27ff27987d54"
- integrity sha1-VBCXI0yyUTyDzu06zdwn/yeYfVQ=
- dependencies:
- cssom "0.3.x"
-
cssstyle@^1.0.0:
version "1.4.0"
resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-1.4.0.tgz#9d31328229d3c565c61e586b02041a28fccdccf1"
@@ -10092,6 +10080,11 @@ dompurify@^1.0.11:
resolved "https://registry.npmjs.org/dompurify/-/dompurify-1.0.11.tgz#fe0f4a40d147f7cebbe31a50a1357539cfc1eb4d"
integrity sha512-XywCTXZtc/qCX3iprD1pIklRVk/uhl8BKpkTxr+ZyMVUzSUg7wkQXRBp/euJ5J5moa1QvfpvaPQVP71z1O59dQ==
+dompurify@^2.0.12:
+ version "2.1.1"
+ resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe"
+ integrity sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg==
+
dompurify@^2.0.7:
version "2.0.12"
resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.0.12.tgz#284a2b041e1c60b8e72d7b4d2fadad36141254ae"
@@ -10552,7 +10545,7 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
-escodegen@^1.14.1, escodegen@^1.6.1, escodegen@^1.9.1:
+escodegen@^1.14.1, escodegen@^1.9.1:
version "1.14.3"
resolved "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"
integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==
@@ -11347,10 +11340,6 @@ file-loader@^6.0.0:
loader-utils "^2.0.0"
schema-utils "^2.7.1"
-file-saver@eligrey/FileSaver.js#1.3.8:
- version "1.3.8"
- resolved "https://codeload.github.com/eligrey/FileSaver.js/tar.gz/e865e37af9f9947ddcced76b549e27dc45c1cb2e"
-
file-system-cache@^1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.0.5.tgz#84259b36a2bbb8d3d6eb1021d3132ffe64cfff4f"
@@ -11986,10 +11975,10 @@ git-url-parse@^11.1.2:
dependencies:
git-up "^4.0.0"
-git-url-parse@^11.2.0:
- version "11.2.0"
- resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.2.0.tgz#2955fd51befd6d96ea1389bbe2ef57e8e6042b04"
- integrity sha512-KPoHZg8v+plarZvto4ruIzzJLFQoRx+sUs5DQSr07By9IBKguVd+e6jwrFR6/TP6xrCJlNV1tPqLO1aREc7O2g==
+git-url-parse@^11.3.0:
+ version "11.3.0"
+ resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.3.0.tgz#1515b4574c4eb2efda7d25cc50b29ce8beaefaae"
+ integrity sha512-i3XNa8IKmqnUqWBcdWBjOcnyZYfN3C1WRvnKI6ouFWwsXCZEnlgbwbm55ZpJ3OJMhfEP/ryFhqW8bBhej3C5Ug==
dependencies:
git-up "^4.0.0"
@@ -12795,12 +12784,12 @@ html-webpack-plugin@^4.2.1, html-webpack-plugin@^4.3.0:
tapable "^1.1.3"
util.promisify "1.0.0"
-html2canvas@1.0.0-alpha.12:
- version "1.0.0-alpha.12"
- resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-alpha.12.tgz#3b1992e3c9b3f56063c35fd620494f37eba88513"
- integrity sha1-OxmS48mz9WBjw1/WIElPN+uohRM=
+html2canvas@^1.0.0-rc.5:
+ version "1.0.0-rc.7"
+ resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-rc.7.tgz#70c159ce0e63954a91169531894d08ad5627ac98"
+ integrity sha512-yvPNZGejB2KOyKleZspjK/NruXVQuowu8NnV2HYG7gW7ytzl+umffbtUI62v2dCHQLDdsK6HIDtyJZ0W3neerA==
dependencies:
- css-line-break "1.0.1"
+ css-line-break "1.1.1"
htmlparser2@^3.3.0:
version "3.10.1"
@@ -13009,7 +12998,7 @@ hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3:
resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48"
integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ==
-iconv-lite@0.4.24, iconv-lite@^0.4.13, iconv-lite@^0.4.21, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
+iconv-lite@0.4.24, iconv-lite@^0.4.21, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.24"
resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -13077,6 +13066,11 @@ immer@1.10.0:
resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
+immer@^7.0.9:
+ version "7.0.9"
+ resolved "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e"
+ integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==
+
immutable@>=3.8.2, immutable@^3.8.1, immutable@^3.8.2, immutable@^3.x.x:
version "3.8.2"
resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
@@ -14538,29 +14532,6 @@ jsdom@^16.2.2:
ws "^7.2.3"
xml-name-validator "^3.0.0"
-jsdom@^8.1.0:
- version "8.5.0"
- resolved "https://registry.npmjs.org/jsdom/-/jsdom-8.5.0.tgz#d4d8f5dbf2768635b62a62823b947cf7071ebc98"
- integrity sha1-1Nj12/J2hjW2KmKCO5R89wcevJg=
- dependencies:
- abab "^1.0.0"
- acorn "^2.4.0"
- acorn-globals "^1.0.4"
- array-equal "^1.0.0"
- cssom ">= 0.3.0 < 0.4.0"
- cssstyle ">= 0.2.34 < 0.3.0"
- escodegen "^1.6.1"
- iconv-lite "^0.4.13"
- nwmatcher ">= 1.3.7 < 2.0.0"
- parse5 "^1.5.1"
- request "^2.55.0"
- sax "^1.1.4"
- symbol-tree ">= 3.1.0 < 4.0.0"
- tough-cookie "^2.2.0"
- webidl-conversions "^3.0.1"
- whatwg-url "^2.0.1"
- xml-name-validator ">= 2.0.1 < 3.0.0"
-
jsesc@^2.5.1:
version "2.5.2"
resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -14759,22 +14730,23 @@ jsonwebtoken@^8.5.1:
ms "^2.1.1"
semver "^5.6.0"
-jspdf-autotable@3.5.3:
- version "3.5.3"
- resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.3.tgz#2f73adb07f340e7dbf22950e3e6c8bf853991479"
- integrity sha512-K+cNWW3x6w0R/1B5m6PYOm6v8CTTDXy/g32lZouc7SuC6zhvzMN2dauhk6dDYxPD0pky0oyPIJFwSJ/tV8PAeg==
+jspdf-autotable@3.5.9:
+ version "3.5.9"
+ resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.9.tgz#8a625ef2aead44271da95e9f649843c401536925"
+ integrity sha512-ZRfiI5P7leJuWmvC0jGVXu227m68C2Jfz1dkDckshmDYDeVFCGxwIBYdCUXJ8Eb2CyFQC2ok82fEWO+xRDovDQ==
-jspdf@1.5.3:
- version "1.5.3"
- resolved "https://registry.npmjs.org/jspdf/-/jspdf-1.5.3.tgz#5a12c011479defabef5735de55c913060ed219f2"
- integrity sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw==
+jspdf@2.1.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/jspdf/-/jspdf-2.1.0.tgz#2322f8644bc41845b3abe20db4c3ca0adeadb84c"
+ integrity sha512-NQygqZEKhSw+nExySJxB72Ge/027YEyIM450Vh/hgay/H9cgZNnkXXOQPRspe9EuCW4sq92zg8hpAXyyBdnaIQ==
dependencies:
- canvg "1.5.3"
- file-saver eligrey/FileSaver.js#1.3.8
- html2canvas "1.0.0-alpha.12"
- omggif "1.0.7"
- promise-polyfill "8.1.0"
- stackblur-canvas "2.2.0"
+ atob "^2.1.2"
+ btoa "^1.2.1"
+ optionalDependencies:
+ canvg "^3.0.6"
+ core-js "^3.6.0"
+ dompurify "^2.0.12"
+ html2canvas "^1.0.0-rc.5"
jsprim@^1.2.2:
version "1.4.1"
@@ -15762,10 +15734,10 @@ material-colors@^1.2.1:
resolved "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"
integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==
-material-table@1.68.0:
- version "1.68.0"
- resolved "https://registry.npmjs.org/material-table/-/material-table-1.68.0.tgz#275c3d9a885c40ae4bc5a7461c00e877f92397b9"
- integrity sha512-dyJJaVsS3m+i6sn71AvYcVdA1P9X1XiUOM2PekfvEeeMtkdQb66oChGkk77ndYi3Ja6j4DovGVNrgeVLwXLZiw==
+material-table@^1.69.1:
+ version "1.69.1"
+ resolved "https://registry.npmjs.org/material-table/-/material-table-1.69.1.tgz#8d1c8b23207f18bd3328cae1b5775ede284682e6"
+ integrity sha512-7MA8kMtr8ToPE6gNUbOGIb4g+RGOLWK8s9gXZYNwFtg6fGAjWEJ+iqBrMmdq7fkMmTRcyOd7/sC/5OPPY8CNGg==
dependencies:
"@date-io/date-fns" "^1.1.0"
"@material-ui/pickers" "^3.2.2"
@@ -15774,8 +15746,8 @@ material-table@1.68.0:
debounce "^1.2.0"
fast-deep-equal "2.0.1"
filefy "0.1.10"
- jspdf "1.5.3"
- jspdf-autotable "3.5.3"
+ jspdf "2.1.0"
+ jspdf-autotable "3.5.9"
prop-types "^15.6.2"
react-beautiful-dnd "^13.0.0"
react-double-scrollbar "0.0.15"
@@ -16863,11 +16835,6 @@ number-is-nan@^1.0.0:
resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-"nwmatcher@>= 1.3.7 < 2.0.0":
- version "1.4.4"
- resolved "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz#2285631f34a95f0d0395cd900c96ed39b58f346e"
- integrity sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==
-
nwsapi@^2.0.7, nwsapi@^2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7"
@@ -17027,11 +16994,6 @@ oidc-token-hash@^5.0.0:
resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888"
integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg==
-omggif@1.0.7:
- version "1.0.7"
- resolved "https://registry.npmjs.org/omggif/-/omggif-1.0.7.tgz#59d2eecb0263de84635b3feb887c0c9973f1e49d"
- integrity sha1-WdLuywJj3oRjWz/riHwMmXPx5J0=
-
on-finished@^2.3.0, on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -17567,11 +17529,6 @@ parse5@5.1.1:
resolved "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
-parse5@^1.5.1:
- version "1.5.1"
- resolved "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
- integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ=
-
parseurl@^1.3.2, parseurl@~1.3.2, parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -18624,11 +18581,6 @@ promise-inflight@^1.0.1:
resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
-promise-polyfill@8.1.0:
- version "8.1.0"
- resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.0.tgz#30059da54d1358ce905ac581f287e184aedf995d"
- integrity sha512-OzSf6gcCUQ01byV4BgwyUCswlaQQ6gzXc23aLQWhicvfX9kfsUiUhgt3CCQej8jDnl8/PhGF31JdHX2/MzF3WA==
-
promise-polyfill@^8.1.3:
version "8.1.3"
resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116"
@@ -20101,7 +20053,7 @@ request-promise-native@^1.0.5, request-promise-native@^1.0.8:
stealthy-require "^1.1.1"
tough-cookie "^2.3.3"
-request@^2.55.0, request@^2.85.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
+request@^2.85.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
version "2.88.2"
resolved "https://registry.npmjs.org/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -20499,7 +20451,7 @@ sanitize-html@^1.27.0:
srcset "^2.0.1"
xtend "^4.0.1"
-sax@>=0.6.0, sax@^1.1.4, sax@^1.2.4, sax@~1.2.4:
+sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
version "1.2.4"
resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -21265,15 +21217,10 @@ stack-utils@^2.0.2:
dependencies:
escape-string-regexp "^2.0.0"
-stackblur-canvas@2.2.0:
- version "2.2.0"
- resolved "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.2.0.tgz#cacc5924a0744b3e183eb2e6c1d8559c1a17c26e"
- integrity sha512-5Gf8dtlf8k6NbLzuly2NkGrkS/Ahh+I5VUjO7TnFizdJtgpfpLLEdQlLe9umbcnZlitU84kfYjXE67xlSXfhfQ==
-
-stackblur-canvas@^1.4.1:
- version "1.4.1"
- resolved "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-1.4.1.tgz#849aa6f94b272ff26f6471fa4130ed1f7e47955b"
- integrity sha1-hJqm+UsnL/JvZHH6QTDtH35HlVs=
+stackblur-canvas@^2.0.0:
+ version "2.4.0"
+ resolved "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.4.0.tgz#2b2eba910cb46f6feae918e1c402f863d602c01b"
+ integrity sha512-Z+HixfgYV0ss3C342DxPwc+UvN1SYWqoz7Wsi3xEDWEnaBkSCL3Ey21gF4io+WlLm8/RIrSnCrDBIEcH4O+q5Q==
stackframe@^1.1.1:
version "1.1.1"
@@ -21895,7 +21842,7 @@ symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0:
resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
-"symbol-tree@>= 3.1.0 < 4.0.0", symbol-tree@^3.2.2, symbol-tree@^3.2.4:
+symbol-tree@^3.2.2, symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
@@ -22375,7 +22322,7 @@ touch@^3.1.0:
dependencies:
nopt "~1.0.10"
-tough-cookie@^2.2.0, tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.5.0:
+tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
@@ -22406,11 +22353,6 @@ tr46@^2.0.2:
dependencies:
punycode "^2.1.1"
-tr46@~0.0.3:
- version "0.0.3"
- resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
- integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
-
traverse@~0.6.6:
version "0.6.6"
resolved "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137"
@@ -23344,11 +23286,6 @@ webapi-parser@^0.5.0:
dependencies:
ajv "6.5.2"
-webidl-conversions@^3.0.0, webidl-conversions@^3.0.1:
- version "3.0.1"
- resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
- integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
-
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
@@ -23551,14 +23488,6 @@ whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0:
resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
-whatwg-url@^2.0.1:
- version "2.0.1"
- resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-2.0.1.tgz#5396b2043f020ee6f704d9c45ea8519e724de659"
- integrity sha1-U5ayBD8CDub3BNnEXqhRnnJN5lk=
- dependencies:
- tr46 "~0.0.3"
- webidl-conversions "^3.0.0"
-
whatwg-url@^6.4.1:
version "6.5.0"
resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
@@ -23873,11 +23802,6 @@ xml-encryption@^1.0.0:
xmldom "~0.1.15"
xpath "0.0.27"
-"xml-name-validator@>= 2.0.1 < 3.0.0":
- version "2.0.1"
- resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635"
- integrity sha1-TYuPHszTQZqjYgYb7O9RXh5VljU=
-
xml-name-validator@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
@@ -23906,7 +23830,7 @@ xmldom@0.1.27:
resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
integrity sha1-1QH5ezvbQDr4757MIFcxh6rawOk=
-xmldom@0.1.x, xmldom@^0.1.22, xmldom@~0.1.15:
+xmldom@0.1.x, xmldom@~0.1.15:
version "0.1.31"
resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==