diff --git a/.changeset/big-cougars-glow.md b/.changeset/big-cougars-glow.md new file mode 100644 index 0000000000..d1630905db --- /dev/null +++ b/.changeset/big-cougars-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/dev-utils': patch +--- + +Add theme switcher to sidebar of dev app. diff --git a/.changeset/early-cobras-explode.md b/.changeset/early-cobras-explode.md new file mode 100644 index 0000000000..34b4cb0e98 --- /dev/null +++ b/.changeset/early-cobras-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Update to the right version of @backstage/errors diff --git a/.changeset/eight-months-agree.md b/.changeset/eight-months-agree.md new file mode 100644 index 0000000000..0f117d8a40 --- /dev/null +++ b/.changeset/eight-months-agree.md @@ -0,0 +1,19 @@ +--- +'@backstage/plugin-gcp-projects': patch +--- + +UI updates to GCP-projects plugin + +Adds the following to the project list page: + +- pagination +- filtering +- sorting +- rows per page +- show/hide columns + +Makes breadcrumb a link back to project list for the project details and new project views. + +In project list page, updates New project button to use RouterLink instead of `href` to avoid login prompt. + +In project details view, links to project details and logs now work, clicking on these will open the project or logs in GCP in new tab. diff --git a/.changeset/forty-ligers-protect.md b/.changeset/forty-ligers-protect.md new file mode 100644 index 0000000000..e3e46fb7f3 --- /dev/null +++ b/.changeset/forty-ligers-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +remove double config dep diff --git a/.changeset/giant-drinks-wave.md b/.changeset/giant-drinks-wave.md new file mode 100644 index 0000000000..ecf4c8fbdd --- /dev/null +++ b/.changeset/giant-drinks-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/dev-utils': patch +--- + +Migrated to using `@backstage/app-defaults`. diff --git a/.changeset/happy-rice-tickle.md b/.changeset/happy-rice-tickle.md new file mode 100644 index 0000000000..1e39dc2e9c --- /dev/null +++ b/.changeset/happy-rice-tickle.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added the `isDatabaseConflictError` function. diff --git a/.changeset/hot-walls-fail.md b/.changeset/hot-walls-fail.md new file mode 100644 index 0000000000..3728e10ff6 --- /dev/null +++ b/.changeset/hot-walls-fail.md @@ -0,0 +1,13 @@ +--- +'@backstage/create-app': patch +--- + +Migrated the app template use the new `@backstage/app-defaults` for the `createApp` import, since the `createApp` exported by `@backstage/app-core-api` will be removed in the future. + +To migrate an existing application, add the latest version of `@backstage/app-defaults` as a dependency in `packages/app/package.json`, and make the following change to `packages/app/src/App.tsx`: + +```diff +-import { createApp, FlatRoutes } from '@backstage/core-app-api'; ++import { createApp } from '@backstage/app-defaults'; ++import { FlatRoutes } from '@backstage/core-app-api'; +``` diff --git a/.changeset/moody-snails-admire.md b/.changeset/moody-snails-admire.md new file mode 100644 index 0000000000..7622546989 --- /dev/null +++ b/.changeset/moody-snails-admire.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': minor +--- + +Tweaked style insertion logic to make sure that JSS stylesheets always receive the highest priority. diff --git a/.changeset/rich-pillows-cough.md b/.changeset/rich-pillows-cough.md new file mode 100644 index 0000000000..959eb69214 --- /dev/null +++ b/.changeset/rich-pillows-cough.md @@ -0,0 +1,12 @@ +--- +'@backstage/backend-common': patch +--- + +Make sure that server builder `start()` propagates errors (such as failing to bind to the required port) properly and doesn't resolve the promise prematurely. + +After this change, the backend logger will be able to actually capture the error as it happens: + +``` +2021-11-11T10:54:21.334Z backstage info Initializing http server +2021-11-11T10:54:21.335Z backstage error listen EADDRINUSE: address already in use :::7000 code=EADDRINUSE errno=-48 syscall=listen address=:: port=7000 +``` diff --git a/.changeset/sharp-moons-jog.md b/.changeset/sharp-moons-jog.md new file mode 100644 index 0000000000..d3329af08e --- /dev/null +++ b/.changeset/sharp-moons-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Deprecated the `theme` property on `AppTheme`, replacing it with `Provider`. See https://backstage.io/docs/api/deprecations#app-theme for more details. diff --git a/.changeset/slow-moles-act.md b/.changeset/slow-moles-act.md new file mode 100644 index 0000000000..ab920bfa5d --- /dev/null +++ b/.changeset/slow-moles-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Migrated to using `createSpecializedApp`. diff --git a/.changeset/twenty-swans-matter.md b/.changeset/twenty-swans-matter.md new file mode 100644 index 0000000000..55cd3d6dc3 --- /dev/null +++ b/.changeset/twenty-swans-matter.md @@ -0,0 +1,9 @@ +--- +'@backstage/core-app-api': patch +--- + +The `createApp` function from `@backstage/core-app-api` has been deprecated, with two new options being provided as a replacement. + +The first and most commonly used one is `createApp` from the new `@backstage/app-defaults` package, which behaves just like the existing `createApp`. In the future this method is likely to be expanded to add more APIs and other pieces into the default setup, for example the Utility APIs from `@backstage/integration-react`. + +The other option that we now provide is to use `createSpecializedApp` from `@backstage/core-app-api`. This is a more low-level API where you need to provide a full set of options, including your own `components`, `icons`, `defaultApis`, and `themes`. The `createSpecializedApp` way of creating an app is particularly useful if you are not using `@backstage/core-components` or MUI, as it allows you to avoid those dependencies completely. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7e478953bc..c14047cd4d 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -52,6 +52,7 @@ configmaps configs const cookiecutter +cron css Datadog dataflow @@ -162,6 +163,8 @@ Monorepo monorepos msgraph msw +mutex +mutexes mysql namespace namespaced diff --git a/docs/api/deprecations.md b/docs/api/deprecations.md new file mode 100644 index 0000000000..774c8ef03d --- /dev/null +++ b/docs/api/deprecations.md @@ -0,0 +1,56 @@ +--- +id: deprecations +title: Deprecations +description: A list of active and past deprecations +--- + +## Introduction + +This page contains extended documentation for some of the deprecations in +various parts of Backstage. It is not an exhaustive list as most deprecation +only come in the form of a changelog notice and a console warning. The +deprecations listed here are the ones that need a bit more guidance than what +fits in a console message. + +### App Theme + +`Released 2021-11-12 in @backstage/core-plugin-api v0.1.13` + +In order to provide more flexibility in what types of themes can be used and how +they are applied, the `theme` property on the `AppTheme` type is being +deprecated and replaced by a `Provider` property instead. The `Provider` +property is a React component that will be mounted at the root of the app +whenever that theme is active. This also removes the tight connection to MUI and +opens up for other type of themes, and removes the hardcoded usage of +``. + +To migrate an existing theme, remove the `theme` property and move it over to a +new `Provider` component, using `ThemeProvider` from MUI to provide the new +theme, along with ``. For example a theme that currently looks like +this: + +```tsx +const darkTheme = { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + icon: , + theme: darkTheme, +}; +``` + +Would be migrated to the following: + +```tsx +const darkTheme = { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + icon: , + Provider: ({ children }) => ( + + {children} + + ), +}; +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 91525d4938..ec5c5d8d68 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -256,7 +256,8 @@ "type": "subcategory", "label": "API Reference", "ids": ["reference/index"] - } + }, + "api/deprecations" ], "Tutorials": [ "tutorials/journey", diff --git a/mkdocs.yml b/mkdocs.yml index 0ddae446e6..8c7114abbc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -164,6 +164,7 @@ nav: - API Reference: - Guides: - Utility APIs: 'api/utility-apis.md' + - Deprecations: 'api/deprecations.md' - Tutorials: - Future developer journey: 'tutorials/journey.md' - Migrating away from @backstage/core: 'tutorials/migrating-away-from-core.md' diff --git a/packages/app-defaults/.eslintrc.js b/packages/app-defaults/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/app-defaults/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/app-defaults/README.md b/packages/app-defaults/README.md new file mode 100644 index 0000000000..15a0bd3d24 --- /dev/null +++ b/packages/app-defaults/README.md @@ -0,0 +1,17 @@ +# @backstage/app-defaults + +This package provides a default wiring of a Backstage app that avoids boilerplate when setting up a standard Backstage app. + +## Installation + +Install the package via Yarn: + +```sh +cd packages/app +yarn add @backstage/app-defaults +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/app-defaults/api-report.md b/packages/app-defaults/api-report.md new file mode 100644 index 0000000000..57ea61ad8b --- /dev/null +++ b/packages/app-defaults/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/app-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AppComponents } from '@backstage/core-app-api'; +import { AppIcons } from '@backstage/core-app-api'; +import { AppOptions } from '@backstage/core-app-api'; +import { AppTheme } from '@backstage/core-plugin-api'; +import { BackstageApp } from '@backstage/core-app-api'; +import { IconComponent } from '@backstage/core-plugin-api'; + +// @public +export function createApp( + options?: Omit & OptionalAppOptions, +): BackstageApp; + +// @public +export type OptionalAppOptions = { + icons?: Partial & { + [key in string]: IconComponent; + }; + themes?: (Partial & Omit)[]; + components?: Partial; +}; +``` diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json new file mode 100644 index 0000000000..9438966b3b --- /dev/null +++ b/packages/app-defaults/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/app-defaults", + "description": "Provides the default wiring of a Backstage App", + "version": "0.1.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/app-defaults" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs types,esm", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core-components": "^0.7.3", + "@backstage/core-app-api": "^0.1.20", + "@backstage/core-plugin-api": "^0.1.13", + "@backstage/theme": "^0.2.13", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "react": "^16.12.0", + "react-router-dom": "6.0.0-beta.0" + }, + "devDependencies": { + "@backstage/cli": "^0.8.2", + "@backstage/test-utils": "^0.1.21", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "@types/react": "*" + }, + "files": [ + "dist" + ] +} diff --git a/packages/app-defaults/src/createApp.test.tsx b/packages/app-defaults/src/createApp.test.tsx new file mode 100644 index 0000000000..45988a4840 --- /dev/null +++ b/packages/app-defaults/src/createApp.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { screen } from '@testing-library/react'; +import { renderWithEffects } from '@backstage/test-utils'; +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { createApp } from './createApp'; + +describe('Optional ThemeProvider', () => { + it('should render app with user-provided ThemeProvider', async () => { + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: MemoryRouter, + ErrorBoundaryFallback: () => null, + ThemeProvider: ({ children }: PropsWithChildren<{}>) => ( +
{children}
+ ), + }; + + const App = createApp({ components }).getProvider(); + + await renderWithEffects(); + + expect(screen.getByRole('main')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-defaults/src/createApp.tsx b/packages/app-defaults/src/createApp.tsx new file mode 100644 index 0000000000..d85dfa04eb --- /dev/null +++ b/packages/app-defaults/src/createApp.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { apis, components, icons, themes } from './defaults'; +import { + AppTheme, + BackstagePlugin, + IconComponent, +} from '@backstage/core-plugin-api'; +import { + AppComponents, + AppOptions, + AppIcons, + createSpecializedApp, +} from '@backstage/core-app-api'; + +/** + * Creates a new Backstage App using a default set of components, icons and themes unless + * they are explicitly provided. + * + * @public + */ +export function createApp( + options?: Omit & OptionalAppOptions, +) { + return createSpecializedApp({ + ...options, + apis: options?.apis ?? [], + bindRoutes: options?.bindRoutes, + components: { + ...components, + ...options?.components, + }, + configLoader: options?.configLoader, + defaultApis: apis, + icons: { + ...icons, + ...options?.icons, + }, + plugins: (options?.plugins as BackstagePlugin[]) ?? [], + themes: options?.themes ?? themes, + }); +} + +/** + * The set of app options that {@link createApp} will provide defaults for + * if they are not passed in explicitly. + * + * @public + */ +export type OptionalAppOptions = { + /** + * A set of icons to override the default icons with. + * + * The override is applied for each icon individually. + * + * @public + */ + icons?: Partial & { + [key in string]: IconComponent; + }; + + /** + * A set of themes that override all of the default app themes. + * + * If this option is provided none of the default themes will be used. + * + * @public + */ + themes?: (Partial & Omit)[]; // TODO: simplify once AppTheme is updated + + /** + * A set of components to override the default components with. + * + * The override is applied for each icon individually. + * + * @public + */ + components?: Partial; +}; diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts new file mode 100644 index 0000000000..d3c76d52bd --- /dev/null +++ b/packages/app-defaults/src/defaults/apis.ts @@ -0,0 +1,274 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AlertApiForwarder, + NoOpAnalyticsApi, + ErrorApiForwarder, + ErrorAlerter, + GoogleAuth, + GithubAuth, + OAuth2, + OktaAuth, + GitlabAuth, + Auth0Auth, + MicrosoftAuth, + BitbucketAuth, + OAuthRequestManager, + WebStorage, + UrlPatternDiscovery, + SamlAuth, + OneLoginAuth, + UnhandledErrorForwarder, + AtlassianAuth, +} from '@backstage/core-app-api'; + +import { + createApiFactory, + alertApiRef, + analyticsApiRef, + errorApiRef, + discoveryApiRef, + oauthRequestApiRef, + googleAuthApiRef, + githubAuthApiRef, + oauth2ApiRef, + oktaAuthApiRef, + gitlabAuthApiRef, + auth0AuthApiRef, + microsoftAuthApiRef, + storageApiRef, + configApiRef, + samlAuthApiRef, + oneloginAuthApiRef, + oidcAuthApiRef, + bitbucketAuthApiRef, + atlassianAuthApiRef, +} from '@backstage/core-plugin-api'; + +export const apis = [ + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`, + ), + }), + createApiFactory({ + api: alertApiRef, + deps: {}, + factory: () => new AlertApiForwarder(), + }), + createApiFactory({ + api: analyticsApiRef, + deps: {}, + factory: () => new NoOpAnalyticsApi(), + }), + createApiFactory({ + api: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => { + const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder()); + UnhandledErrorForwarder.forward(errorApi, { hidden: false }); + return errorApi; + }, + }), + createApiFactory({ + api: storageApiRef, + deps: { errorApi: errorApiRef }, + factory: ({ errorApi }) => WebStorage.create({ errorApi }), + }), + createApiFactory({ + api: oauthRequestApiRef, + deps: {}, + factory: () => new OAuthRequestManager(), + }), + createApiFactory({ + api: googleAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GoogleAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: microsoftAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + MicrosoftAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: githubAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oktaAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OktaAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: gitlabAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GitlabAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: auth0AuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + Auth0Auth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oauth2ApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: samlAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => + SamlAuth.create({ + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oneloginAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OneLoginAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: oidcAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'oidc', + title: 'Your Identity Provider', + icon: () => null, + }, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: bitbucketAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + BitbucketAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['team'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + createApiFactory({ + api: atlassianAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => { + return AtlassianAuth.create({ + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), +]; diff --git a/packages/app-defaults/src/defaults/components.test.tsx b/packages/app-defaults/src/defaults/components.test.tsx new file mode 100644 index 0000000000..865eeed8a7 --- /dev/null +++ b/packages/app-defaults/src/defaults/components.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { OptionallyWrapInRouter } from './components'; + +describe('OptionallyWrapInRouter', () => { + it('should wrap with router if not yet inside a router', async () => { + render(Test); + + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('should not wrap with router if already inside a router', async () => { + render( + + Test + , + ); + + expect(screen.getByText('Test')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-defaults/src/defaults/components.tsx b/packages/app-defaults/src/defaults/components.tsx new file mode 100644 index 0000000000..139bea4862 --- /dev/null +++ b/packages/app-defaults/src/defaults/components.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import Button from '@material-ui/core/Button'; +import { ErrorPanel, Progress, ErrorPage } from '@backstage/core-components'; +import { + MemoryRouter, + useInRouterContext, + BrowserRouter, +} from 'react-router-dom'; +import { + AppComponents, + BootErrorPageProps, + ErrorBoundaryFallbackProps, +} from '@backstage/core-plugin-api'; + +export function OptionallyWrapInRouter({ children }: { children: ReactNode }) { + if (useInRouterContext()) { + return <>{children}; + } + return {children}; +} + +const DefaultNotFoundPage = () => ( + +); + +const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => { + let message = ''; + if (step === 'load-config') { + message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; + } else if (step === 'load-chunk') { + message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`; + } + // TODO: figure out a nicer way to handle routing on the error page, when it can be done. + return ( + + + + ); +}; + +const DefaultErrorBoundaryFallback = ({ + error, + resetError, + plugin, +}: ErrorBoundaryFallbackProps) => { + return ( + + + + ); +}; + +/** + * Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}. + * + * @public + */ +export const components: AppComponents = { + Progress, + Router: BrowserRouter, + NotFoundErrorPage: DefaultNotFoundPage, + BootErrorPage: DefaultBootErrorPage, + ErrorBoundaryFallback: DefaultErrorBoundaryFallback, +}; diff --git a/packages/core-app-api/src/app/icons.tsx b/packages/app-defaults/src/defaults/icons.tsx similarity index 63% rename from packages/core-app-api/src/app/icons.tsx rename to packages/app-defaults/src/defaults/icons.tsx index b234a292b1..c4f2d2b7e4 100644 --- a/packages/core-app-api/src/app/icons.tsx +++ b/packages/app-defaults/src/defaults/icons.tsx @@ -35,52 +35,27 @@ import MuiPeopleIcon from '@material-ui/icons/People'; import MuiPersonIcon from '@material-ui/icons/Person'; import MuiWarningIcon from '@material-ui/icons/Warning'; -type AppIconsKey = - | 'brokenImage' - | 'catalog' - | 'scaffolder' - | 'techdocs' - | 'search' - | 'chat' - | 'dashboard' - | 'docs' - | 'email' - | 'github' - | 'group' - | 'help' - | 'kind:api' - | 'kind:component' - | 'kind:domain' - | 'kind:group' - | 'kind:location' - | 'kind:system' - | 'kind:user' - | 'user' - | 'warning'; - -export type AppIcons = { [key in AppIconsKey]: IconComponent }; - -export const defaultAppIcons: AppIcons = { - brokenImage: MuiBrokenImageIcon, +export const icons = { + brokenImage: MuiBrokenImageIcon as IconComponent, // To be confirmed: see https://github.com/backstage/backstage/issues/4970 - catalog: MuiMenuBookIcon, - scaffolder: MuiCreateNewFolderIcon, - techdocs: MuiSubjectIcon, - search: MuiSearchIcon, - chat: MuiChatIcon, - dashboard: MuiDashboardIcon, - docs: MuiDocsIcon, - email: MuiEmailIcon, - github: MuiGitHubIcon, - group: MuiPeopleIcon, - help: MuiHelpIcon, - 'kind:api': MuiExtensionIcon, - 'kind:component': MuiMemoryIcon, - 'kind:domain': MuiApartmentIcon, - 'kind:group': MuiPeopleIcon, - 'kind:location': MuiLocationOnIcon, - 'kind:system': MuiCategoryIcon, - 'kind:user': MuiPersonIcon, - user: MuiPersonIcon, - warning: MuiWarningIcon, + catalog: MuiMenuBookIcon as IconComponent, + scaffolder: MuiCreateNewFolderIcon as IconComponent, + techdocs: MuiSubjectIcon as IconComponent, + search: MuiSearchIcon as IconComponent, + chat: MuiChatIcon as IconComponent, + dashboard: MuiDashboardIcon as IconComponent, + docs: MuiDocsIcon as IconComponent, + email: MuiEmailIcon as IconComponent, + github: MuiGitHubIcon as IconComponent, + group: MuiPeopleIcon as IconComponent, + help: MuiHelpIcon as IconComponent, + 'kind:api': MuiExtensionIcon as IconComponent, + 'kind:component': MuiMemoryIcon as IconComponent, + 'kind:domain': MuiApartmentIcon as IconComponent, + 'kind:group': MuiPeopleIcon as IconComponent, + 'kind:location': MuiLocationOnIcon as IconComponent, + 'kind:system': MuiCategoryIcon as IconComponent, + 'kind:user': MuiPersonIcon as IconComponent, + user: MuiPersonIcon as IconComponent, + warning: MuiWarningIcon as IconComponent, }; diff --git a/packages/app-defaults/src/defaults/index.ts b/packages/app-defaults/src/defaults/index.ts new file mode 100644 index 0000000000..d9ff18bc8c --- /dev/null +++ b/packages/app-defaults/src/defaults/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { apis } from './apis'; +export { components } from './components'; +export { icons } from './icons'; +export { themes } from './themes'; diff --git a/packages/app-defaults/src/defaults/themes.tsx b/packages/app-defaults/src/defaults/themes.tsx new file mode 100644 index 0000000000..90a4a0f47f --- /dev/null +++ b/packages/app-defaults/src/defaults/themes.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { darkTheme, lightTheme } from '@backstage/theme'; +import DarkIcon from '@material-ui/icons/Brightness2'; +import LightIcon from '@material-ui/icons/WbSunny'; +import { ThemeProvider } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import { AppTheme } from '@backstage/core-plugin-api'; + +export const themes: AppTheme[] = [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + icon: , + theme: lightTheme, + Provider: ({ children }) => ( + + {children} + + ), + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + icon: , + theme: darkTheme, + Provider: ({ children }) => ( + + {children} + + ), + }, +]; diff --git a/packages/app-defaults/src/index.ts b/packages/app-defaults/src/index.ts new file mode 100644 index 0000000000..16a6a693a5 --- /dev/null +++ b/packages/app-defaults/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Provides the default wiring of a Backstage App + * + * @packageDocumentation + */ + +export { createApp } from './createApp'; +export type { OptionalAppOptions } from './createApp'; diff --git a/packages/app-defaults/src/setupTests.ts b/packages/app-defaults/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/app-defaults/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/packages/app/package.json b/packages/app/package.json index 833e146d45..9a1fb108f6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -4,6 +4,7 @@ "private": true, "bundled": true, "dependencies": { + "@backstage/app-defaults": "^0.1.0", "@backstage/catalog-model": "^0.9.5", "@backstage/cli": "^0.8.0", "@backstage/core-app-api": "^0.1.18", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 109ae7922e..401665b62e 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,7 +26,8 @@ import { RELATION_PART_OF, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; import { AlertDisplay, OAuthRequestDialog, @@ -93,7 +94,6 @@ const app = createApp({ // Custom icon example alert: AlarmIcon, }, - components: { SignInPage: props => { return ( diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index a19447034e..7242c32a84 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -374,6 +374,9 @@ export class GitlabUrlReader implements UrlReader { export { isChildPath }; +// @public +export function isDatabaseConflictError(e: unknown): boolean; + // @public export function loadBackendConfig(options: { logger: Logger_2; diff --git a/packages/backend-common/src/database/DatabaseManager.ts b/packages/backend-common/src/database/DatabaseManager.ts index c0511c6b78..f8cdab1413 100644 --- a/packages/backend-common/src/database/DatabaseManager.ts +++ b/packages/backend-common/src/database/DatabaseManager.ts @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Knex } from 'knex'; import { omit } from 'lodash'; import { Config, ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { createDatabaseClient, - ensureDatabaseExists, createNameOverride, + ensureDatabaseExists, normalizeConnection, } from './connection'; import { PluginDatabaseManager } from './types'; @@ -165,7 +166,7 @@ export class DatabaseManager { ); return { - // include base connection if client type has not been overriden + // include base connection if client type has not been overridden ...(overridden ? {} : baseConnection), ...connection, }; diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts index 7fcb8bf930..2a820f7367 100644 --- a/packages/backend-common/src/database/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -28,3 +28,4 @@ export { } from './connection'; export type { PluginDatabaseManager } from './types'; +export { isDatabaseConflictError } from './util'; diff --git a/packages/backend-common/src/database/util.ts b/packages/backend-common/src/database/util.ts new file mode 100644 index 0000000000..607554f294 --- /dev/null +++ b/packages/backend-common/src/database/util.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tries to deduce whether a thrown error is a database conflict. + * + * @public + * @param e - A thrown error + * @returns True if the error looks like it was a conflict error thrown by a + * known database engine + */ +export function isDatabaseConflictError(e: unknown) { + const message = (e as any)?.message; + + return ( + typeof message === 'string' && + (/SQLITE_CONSTRAINT: UNIQUE/.test(message) || + /unique constraint/.test(message)) + ); +} diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index ed16c9c9a7..885baba54b 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -174,27 +174,27 @@ export class ServiceBuilderImpl implements ServiceBuilder { const server: http.Server = httpsSettings ? await createHttpsServer(app, httpsSettings, logger) : createHttpServer(app, logger); + const stoppableServer = stoppable(server, 0); + + useHotCleanup(this.module, () => + stoppableServer.stop((e: any) => { + if (e) console.error(e); + }), + ); return new Promise((resolve, reject) => { - app.on('error', e => { - logger.error(`Failed to start up on port ${port}, ${e}`); + function handleStartupError(e: unknown) { + server.close(); reject(e); + } + + server.on('error', handleStartupError); + + server.listen(port, host, () => { + server.off('error', handleStartupError); + logger.info(`Listening on ${host}:${port}`); + resolve(stoppableServer); }); - - const stoppableServer = stoppable( - server.listen(port, host, () => { - logger.info(`Listening on ${host}:${port}`); - }), - 0, - ); - - useHotCleanup(this.module, () => - stoppableServer.stop((e: any) => { - if (e) console.error(e); - }), - ); - - resolve(stoppableServer); }); } diff --git a/packages/backend-tasks/.eslintrc.js b/packages/backend-tasks/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/backend-tasks/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/backend-tasks/README.md b/packages/backend-tasks/README.md new file mode 100644 index 0000000000..d3c361c02c --- /dev/null +++ b/packages/backend-tasks/README.md @@ -0,0 +1,35 @@ +# @backstage/backend-tasks + +Common distributed task management for Backstage backends. + +## Usage + +Add the library to your backend package: + +```sh +# From your Backstage root directory +cd packages/backend +yarn add @backstage/backend-tasks +``` + +then make use of its facilities as necessary: + +```typescript +import { TaskScheduler } from '@backstage/backend-tasks'; +import { Duration } from 'luxon'; + +const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin'); + +await scheduler.scheduleTask({ + id: 'refresh-things', + frequency: Duration.fromObject({ minutes: 10 }), + fn: async () => { + await entityProvider.run(); + }, +}); +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/backend-tasks/api-report.md b/packages/backend-tasks/api-report.md new file mode 100644 index 0000000000..eeb6412840 --- /dev/null +++ b/packages/backend-tasks/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/backend-tasks" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; +import { Config } from '@backstage/config'; +import { DatabaseManager } from '@backstage/backend-common'; +import { Duration } from 'luxon'; +import { Logger as Logger_2 } from 'winston'; + +// @public +export interface PluginTaskScheduler { + scheduleTask(task: TaskDefinition): Promise; +} + +// @public +export interface TaskDefinition { + fn: TaskFunction; + frequency: Duration; + id: string; + initialDelay?: Duration; + signal?: AbortSignal_2; + timeout: Duration; +} + +// @public +export type TaskFunction = + | ((abortSignal: AbortSignal_2) => void | Promise) + | (() => void | Promise); + +// @public +export class TaskScheduler { + constructor(databaseManager: DatabaseManager, logger: Logger_2); + forPlugin(pluginId: string): PluginTaskScheduler; + // (undocumented) + static fromConfig( + config: Config, + options?: { + databaseManager?: DatabaseManager; + logger?: Logger_2; + }, + ): TaskScheduler; +} +``` diff --git a/packages/backend-tasks/migrations/20210928160613_init.js b/packages/backend-tasks/migrations/20210928160613_init.js new file mode 100644 index 0000000000..21d80ae24c --- /dev/null +++ b/packages/backend-tasks/migrations/20210928160613_init.js @@ -0,0 +1,64 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // + // tasks + // + await knex.schema.createTable('backstage_backend_tasks__tasks', table => { + table.comment('Tasks used for scheduling work on multiple workers'); + table + .text('id') + .primary() + .notNullable() + .comment('The unique ID of this particular task'); + table + .text('settings_json') + .notNullable() + .comment('JSON serialized object with properties for this task'); + table + .dateTime('next_run_start_at') + .notNullable() + .comment('The next time that the task should be started'); + table + .text('current_run_ticket') + .nullable() + .comment('A unique ticket for the current task run'); + table + .dateTime('current_run_started_at') + .nullable() + .comment('The time that the current task run started'); + table + .dateTime('current_run_expires_at') + .nullable() + .comment('The time that the current task run will time out'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // + // tasks + // + await knex.schema.dropTable('backstage_backend_tasks__tasks'); +}; diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json new file mode 100644 index 0000000000..10db5cfaac --- /dev/null +++ b/packages/backend-tasks/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/backend-tasks", + "description": "Common distributed task management library for Backstage backends", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-tasks" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.8", + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.1.4", + "@backstage/types": "^0.1.1", + "@types/luxon": "^2.0.4", + "knex": "^0.95.1", + "lodash": "^4.17.21", + "luxon": "^2.0.2", + "node-abort-controller": "^3.0.1", + "uuid": "^8.0.0", + "winston": "^3.2.1", + "zod": "^3.9.5" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^0.1.8", + "@backstage/cli": "^0.8.1", + "jest": "^26.0.1", + "wait-for-expect": "^3.0.2" + }, + "files": [ + "dist", + "migrations/**/*.{js,d.ts}" + ] +} diff --git a/packages/backend-tasks/src/database/migrateBackendTasks.ts b/packages/backend-tasks/src/database/migrateBackendTasks.ts new file mode 100644 index 0000000000..cc0f4349ac --- /dev/null +++ b/packages/backend-tasks/src/database/migrateBackendTasks.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolvePackagePath } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DB_MIGRATIONS_TABLE } from './tables'; + +const migrationsDir = resolvePackagePath( + '@backstage/backend-tasks', + 'migrations', +); + +export async function migrateBackendTasks(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + tableName: DB_MIGRATIONS_TABLE, + }); +} diff --git a/packages/backend-tasks/src/database/tables.ts b/packages/backend-tasks/src/database/tables.ts new file mode 100644 index 0000000000..63aad6e42a --- /dev/null +++ b/packages/backend-tasks/src/database/tables.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const DB_MIGRATIONS_TABLE = 'backstage_backend_tasks__knex_migrations'; +export const DB_TASKS_TABLE = 'backstage_backend_tasks__tasks'; + +export type DbTasksRow = { + id: string; + settings_json: string; + next_run_start_at: Date; + current_run_ticket?: string; + current_run_started_at?: Date | string; + current_run_expires_at?: Date | string; +}; diff --git a/packages/backend-tasks/src/index.ts b/packages/backend-tasks/src/index.ts new file mode 100644 index 0000000000..dd75aca68c --- /dev/null +++ b/packages/backend-tasks/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common distributed task management library for Backstage backends + * + * @packageDocumentation + */ + +export * from './tasks'; diff --git a/packages/backend-tasks/src/setupTests.ts b/packages/backend-tasks/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/packages/backend-tasks/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts new file mode 100644 index 0000000000..e387b85413 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; + +describe('PluginTaskManagerImpl', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function init(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + const manager = new PluginTaskSchedulerImpl( + async () => knex, + getVoidLogger(), + ); + return { knex, manager }; + } + + // This is just to test the wrapper code; most of the actual tests are in + // TaskWorker.test.ts + describe('scheduleTask', () => { + it.each(databases.eachSupportedId())( + 'can run the happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); + }); +}); diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts new file mode 100644 index 0000000000..93975bc327 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; +import { Logger } from 'winston'; +import { TaskWorker } from './TaskWorker'; +import { PluginTaskScheduler, TaskDefinition } from './types'; +import { validateId } from './util'; + +/** + * Implements the actual task management. + */ +export class PluginTaskSchedulerImpl implements PluginTaskScheduler { + constructor( + private readonly databaseFactory: () => Promise, + private readonly logger: Logger, + ) {} + + async scheduleTask(task: TaskDefinition): Promise { + validateId(task.id); + + const knex = await this.databaseFactory(); + + const worker = new TaskWorker(task.id, task.fn, knex, this.logger); + await worker.start( + { + version: 1, + initialDelayDuration: task.initialDelay?.toISO(), + recurringAtMostEveryDuration: task.frequency.toISO(), + timeoutAfterDuration: task.timeout.toISO(), + }, + { + signal: task.signal, + }, + ); + } +} diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts new file mode 100644 index 0000000000..8b90afff42 --- /dev/null +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerJanitor.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { Logger } from 'winston'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { sleep } from './util'; + +/** + * Makes sure to auto-expire and clean up things that time out or for other + * reasons should not be left lingering. + */ +export class PluginTaskSchedulerJanitor { + private readonly knex: Knex; + private readonly waitBetweenRuns: Duration; + private readonly logger: Logger; + + constructor(options: { + knex: Knex; + waitBetweenRuns: Duration; + logger: Logger; + }) { + this.knex = options.knex; + this.waitBetweenRuns = options.waitBetweenRuns; + this.logger = options.logger; + } + + async start(abortSignal?: AbortSignal) { + while (!abortSignal?.aborted) { + try { + await this.runOnce(); + } catch (e) { + this.logger.warn(`Error while performing janitorial tasks, ${e}`); + } + + await sleep(this.waitBetweenRuns, abortSignal); + } + } + + private async runOnce() { + // SQLite currently (Oct 1 2021) returns a number for returning() + // statements, effectively ignoring them and instead returning the outcome + // of the delete() - and knex also emits a warning about that fact, which + // is why we avoid that entirely for the sqlite3 driver. + // https://github.com/knex/knex/issues/4370 + // https://github.com/mapbox/node-sqlite3/issues/1453 + + const dbNull = this.knex.raw('null'); + + const tasksQuery = this.knex(DB_TASKS_TABLE) + .where('current_run_expires_at', '<', this.knex.fn.now()) + .update({ + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + + if (this.knex.client.config.client === 'sqlite3') { + const tasks = await tasksQuery; + this.logger.warn(`${tasks} tasks timed out and were lost`); + } else { + const tasks = await tasksQuery.returning(['id']); + for (const { id } of tasks) { + this.logger.warn(`Task timed out and was lost: ${id}`); + } + } + } +} diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.test.ts b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts new file mode 100644 index 0000000000..ce8e797503 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import { TaskScheduler } from './TaskScheduler'; +import waitForExpect from 'wait-for-expect'; + +describe('TaskScheduler', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function createDatabase( + databaseId: TestDatabaseId, + ): Promise { + const knex = await databases.init(databaseId); + const databaseManager: Partial = { + forPlugin: () => ({ + getClient: async () => knex, + }), + }; + return databaseManager as DatabaseManager; + } + + it.each(databases.eachSupportedId())( + 'can return a working plugin impl, %p', + async databaseId => { + const database = await createDatabase(databaseId); + const manager = new TaskScheduler(database, logger).forPlugin('test'); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); +}); diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.ts b/packages/backend-tasks/src/tasks/TaskScheduler.ts new file mode 100644 index 0000000000..f1668a6691 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskScheduler.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DatabaseManager, getRootLogger } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { once } from 'lodash'; +import { Duration } from 'luxon'; +import { Logger } from 'winston'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; +import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor'; +import { PluginTaskScheduler } from './types'; + +/** + * Deals with the scheduling of distributed tasks. + * + * @public + */ +export class TaskScheduler { + static fromConfig( + config: Config, + options?: { + databaseManager?: DatabaseManager; + logger?: Logger; + }, + ): TaskScheduler { + const databaseManager = + options?.databaseManager ?? DatabaseManager.fromConfig(config); + const logger = (options?.logger || getRootLogger()).child({ + type: 'taskManager', + }); + return new TaskScheduler(databaseManager, logger); + } + + constructor( + private readonly databaseManager: DatabaseManager, + private readonly logger: Logger, + ) {} + + /** + * Instantiates a task manager instance for the given plugin. + * + * @param pluginId - The unique ID of the plugin, for example "catalog" + * @returns A {@link PluginTaskScheduler} instance + */ + forPlugin(pluginId: string): PluginTaskScheduler { + const databaseFactory = once(async () => { + const knex = await this.databaseManager.forPlugin(pluginId).getClient(); + + await migrateBackendTasks(knex); + + const janitor = new PluginTaskSchedulerJanitor({ + knex, + waitBetweenRuns: Duration.fromObject({ minutes: 1 }), + logger: this.logger, + }); + janitor.start(); + + return knex; + }); + + return new PluginTaskSchedulerImpl( + databaseFactory, + this.logger.child({ plugin: pluginId }), + ); + } +} diff --git a/packages/backend-tasks/src/tasks/TaskWorker.test.ts b/packages/backend-tasks/src/tasks/TaskWorker.test.ts new file mode 100644 index 0000000000..2f977ca639 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskWorker.test.ts @@ -0,0 +1,245 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { TaskWorker } from './TaskWorker'; +import { TaskSettingsV1 } from './types'; + +describe('TaskWorker', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it.each(databases.eachSupportedId())( + 'goes through the expected states, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV1 = { + version: 1, + initialDelayDuration: Duration.fromMillis(1000).toISO(), + recurringAtMostEveryDuration: Duration.fromMillis(2000).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + expect(JSON.parse(row.settings_json)).toEqual({ + version: 1, + initialDelayDuration: 'PT1S', + recurringAtMostEveryDuration: 'PT2S', + timeoutAfterDuration: 'PT60S', + }); + + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'not-ready-yet', + }); + + waitForExpect(async () => { + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + }); + }); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + true, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'runs tasks more than once even when the task throws, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn().mockRejectedValue(new Error('failed')); + const settings: TaskSettingsV1 = { + version: 1, + initialDelayDuration: undefined, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + worker.start(settings); + + waitForExpect(() => { + expect(fn).toBeCalledTimes(3); + }); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'does not clobber ticket lock when stolen, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV1 = { + version: 1, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await knex(DB_TASKS_TABLE) + .where('id', '=', 'task1') + .update({ current_run_ticket: 'stolen' }); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'stolen', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'gracefully handles a disappeared task row, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn(async () => {}); + const settings: TaskSettingsV1 = { + version: 1, + recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + }; + + const worker1 = new TaskWorker('task1', fn, knex, logger); + await worker1.persistTask(settings); + await knex(DB_TASKS_TABLE).where('id', '=', 'task1').delete(); + await expect(worker1.findReadyTask()).resolves.toEqual({ + result: 'abort', + }); + + const worker2 = new TaskWorker('task2', fn, knex, logger); + await worker2.persistTask(settings); + await expect(worker2.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await knex(DB_TASKS_TABLE).where('id', '=', 'task2').delete(); + await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe( + false, + ); + + const worker3 = new TaskWorker('task3', fn, knex, logger); + await worker3.persistTask(settings); + await expect(worker3.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe( + true, + ); + await knex(DB_TASKS_TABLE).where('id', '=', 'task3').delete(); + await expect(worker3.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + }, + 60_000, + ); +}); diff --git a/packages/backend-tasks/src/tasks/TaskWorker.ts b/packages/backend-tasks/src/tasks/TaskWorker.ts new file mode 100644 index 0000000000..991d97b956 --- /dev/null +++ b/packages/backend-tasks/src/tasks/TaskWorker.ts @@ -0,0 +1,249 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { v4 as uuid } from 'uuid'; +import { Logger } from 'winston'; +import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; +import { TaskFunction, TaskSettingsV1, taskSettingsV1Schema } from './types'; +import { delegateAbortController, nowPlus, sleep } from './util'; + +const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 }); + +/** + * Performs the actual work of a task. + * + * @private + */ +export class TaskWorker { + private readonly taskId: string; + private readonly fn: TaskFunction; + private readonly knex: Knex; + private readonly logger: Logger; + + constructor(taskId: string, fn: TaskFunction, knex: Knex, logger: Logger) { + this.taskId = taskId; + this.fn = fn; + this.knex = knex; + this.logger = logger; + } + + async start(settings: TaskSettingsV1, options?: { signal?: AbortSignal }) { + try { + await this.persistTask(settings); + } catch (e) { + throw new Error(`Failed to persist task, ${e}`); + } + + this.logger.info( + `Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`, + ); + + (async () => { + try { + while (!options?.signal?.aborted) { + const runResult = await this.runOnce(options?.signal); + if (runResult.result === 'abort') { + break; + } + + await sleep(WORK_CHECK_FREQUENCY, options?.signal); + } + this.logger.info(`Task worker finished: ${this.taskId}`); + } catch (e) { + this.logger.warn(`Task worker failed unexpectedly, ${e}`); + } + })(); + } + + /** + * Makes a single attempt at running the task to completion, if ready. + * + * @returns The outcome of the attempt + */ + async runOnce( + signal?: AbortSignal, + ): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'failed' } + | { result: 'completed' } + > { + const findResult = await this.findReadyTask(); + if ( + findResult.result === 'not-ready-yet' || + findResult.result === 'abort' + ) { + return findResult; + } + + const taskSettings = findResult.settings; + const ticket = uuid(); + + const claimed = await this.tryClaimTask(ticket, taskSettings); + if (!claimed) { + return { result: 'not-ready-yet' }; + } + + // Abort the task execution either if the worker is stopped, or if the + // task timeout is hit + const taskAbortController = delegateAbortController(signal); + const timeoutHandle = setTimeout(() => { + taskAbortController.abort(); + }, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds')); + + try { + await this.fn(taskAbortController.signal); + } catch (e) { + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'failed' }; + } finally { + clearTimeout(timeoutHandle); + } + + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'completed' }; + } + + /** + * Perform the initial store of the task info + */ + async persistTask(settings: TaskSettingsV1) { + // Perform an initial parse to ensure that we will definitely be able to + // read it back again. + taskSettingsV1Schema.parse(settings); + + const settingsJson = JSON.stringify(settings); + const startAt = settings.initialDelayDuration + ? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex) + : this.knex.fn.now(); + + // It's OK if the task already exists; if it does, just replace its + // settings with the new value and start the loop as usual. + await this.knex(DB_TASKS_TABLE) + .insert({ + id: this.taskId, + settings_json: settingsJson, + next_run_start_at: startAt, + }) + .onConflict('id') + .merge(['settings_json']); + } + + /** + * Check if the task is ready to run + */ + async findReadyTask(): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'ready'; settings: TaskSettingsV1 } + > { + const [row] = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .select({ + settingsJson: 'settings_json', + ready: this.knex.raw( + ` + CASE + WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE + ELSE FALSE + END`, + [this.knex.fn.now()], + ), + }); + + if (!row) { + this.logger.info( + 'No longer able to find task; aborting and assuming that it has been unregistered or expired', + ); + return { result: 'abort' }; + } else if (!row.ready) { + return { result: 'not-ready-yet' }; + } + + try { + const settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson)); + return { result: 'ready', settings }; + } catch (e) { + this.logger.info( + `Task "${this.taskId}" is no longer able to parse task settings; aborting and assuming that a ` + + `newer version of the task has been issued and being handled by other workers, ${e}`, + ); + return { result: 'abort' }; + } + } + + /** + * Attempts to claim a task that's ready for execution, on this worker's + * behalf. We should not attempt to perform the work unless the claim really + * goes through. + * + * @param ticket - A globally unique string that changes for each invocation + * @param settings - The settings of the task to claim + * @returns True if it was successfully claimed + */ + async tryClaimTask( + ticket: string, + settings: TaskSettingsV1, + ): Promise { + const startedAt = this.knex.fn.now(); + const expiresAt = settings.timeoutAfterDuration + ? nowPlus(Duration.fromISO(settings.timeoutAfterDuration), this.knex) + : this.knex.raw('null'); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .whereNull('current_run_ticket') + .update({ + current_run_ticket: ticket, + current_run_started_at: startedAt, + current_run_expires_at: expiresAt, + }); + + return rows === 1; + } + + async tryReleaseTask( + ticket: string, + settings: TaskSettingsV1, + ): Promise { + const { recurringAtMostEveryDuration } = settings; + + // We make an effort to keep the datetime calculations in the database + // layer, making sure to not have to perform conversions back and forth and + // leaning on the database as a central clock source + const dbNull = this.knex.raw('null'); + const dt = Duration.fromISO(recurringAtMostEveryDuration).as('seconds'); + const nextRun = + this.knex.client.config.client === 'sqlite3' + ? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`]) + : this.knex.raw(`next_run_start_at + interval '${dt} seconds'`); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .where('current_run_ticket', '=', ticket) + .update({ + next_run_start_at: nextRun, + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + + return rows === 1; + } +} diff --git a/packages/backend-tasks/src/tasks/index.ts b/packages/backend-tasks/src/tasks/index.ts new file mode 100644 index 0000000000..9e0a06f71c --- /dev/null +++ b/packages/backend-tasks/src/tasks/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TaskScheduler } from './TaskScheduler'; +export type { + PluginTaskScheduler, + TaskDefinition, + TaskFunction, +} from './types'; diff --git a/packages/backend-tasks/src/tasks/types.ts b/packages/backend-tasks/src/tasks/types.ts new file mode 100644 index 0000000000..4693af7ef3 --- /dev/null +++ b/packages/backend-tasks/src/tasks/types.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { z } from 'zod'; + +/** + * A function that can be called as a scheduled task. + * + * It may optionally accept an abort signal argument. When the signal triggers, + * processing should abort and return as quickly as possible. + * + * @public + */ +export type TaskFunction = + | ((abortSignal: AbortSignal) => void | Promise) + | (() => void | Promise); + +/** + * Options that apply to the invocation of a given task. + * + * @public + */ +export interface TaskDefinition { + /** + * A unique ID (within the scope of the plugin) for the task. + */ + id: string; + + /** + * The actual task function to be invoked regularly. + */ + fn: TaskFunction; + + /** + * An abort signal that, when triggered, will stop the recurring execution of + * the task. + */ + signal?: AbortSignal; + + /** + * The maximum amount of time that a single task invocation can take, before + * it's considered timed out and gets "released" such that a new invocation + * is permitted to take place (possibly, then, on a different worker). + * + * If no value is given for this field then there is no timeout. This is + * potentially dangerous. + */ + timeout: Duration; + + /** + * The amount of time that should pass between task invocation starts. + * Essentially, this equals roughly how often you want the task to run. + * + * This is a best effort value; under some circumstances there can be + * deviations. For example, if the task runtime is longer than the frequency + * and the timeout has not been given or not been exceeded yet, the next + * invocation of this task will be delayed until after the previous one + * finishes. + * + * The system does its best to avoid overlapping invocations. + * + * If no value is given for this field then the task will only be invoked + * once (on any worker) and then unscheduled automatically. + */ + frequency: Duration; + + /** + * The amount of time that should pass before the first invocation happens. + * + * This can be useful in cold start scenarios to stagger or delay some heavy + * compute jobs. + * + * If no value is given for this field then the first invocation will happen + * as soon as possible. + */ + initialDelay?: Duration; +} + +/** + * Deals with the scheduling of distributed tasks, for a given plugin. + * + * @public + */ +export interface PluginTaskScheduler { + /** + * Schedules a task function for coordinated exclusive invocation across + * workers. + * + * If the task was already scheduled since before by us or by another party, + * its options are just overwritten with the given options, and things + * continue from there. + * + * @param definition - The task definition + */ + scheduleTask(task: TaskDefinition): Promise; +} + +function isValidOptionalDurationString(d: string | undefined): boolean { + try { + return !d || Duration.fromISO(d).isValid === true; + } catch { + return false; + } +} + +export const taskSettingsV1Schema = z.object({ + version: z.literal(1), + initialDelayDuration: z + .string() + .optional() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + recurringAtMostEveryDuration: z + .string() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + timeoutAfterDuration: z + .string() + .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), +}); + +/** + * The properties that control a scheduled task (version 1). + */ +export type TaskSettingsV1 = z.infer; diff --git a/packages/backend-tasks/src/tasks/util.test.ts b/packages/backend-tasks/src/tasks/util.test.ts new file mode 100644 index 0000000000..f74669a7f4 --- /dev/null +++ b/packages/backend-tasks/src/tasks/util.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from 'luxon'; +import { AbortController } from 'node-abort-controller'; +import { delegateAbortController, sleep, validateId } from './util'; + +describe('util', () => { + describe('validateId', () => { + it.each(['a', 'a_b', 'ab123c_2'])( + 'accepts valid inputs, %p', + async input => { + expect(validateId(input)).toBeUndefined(); + }, + ); + + it.each(['', 'a!', 'A', 'a-b', 'a.b', '_a', 'a_', null, Symbol('a')])( + 'rejects invalid inputs, %p', + async input => { + expect(() => validateId(input as any)).toThrow(); + }, + ); + }); + + describe('sleep', () => { + it('finishes the wait as expected with no signal', async () => { + const ac = new AbortController(); + const start = Date.now(); + await sleep(Duration.fromObject({ seconds: 1 }), ac.signal); + expect(Date.now() - start).toBeGreaterThan(800); + }, 5_000); + + it('aborts properly on the signal', async () => { + const ac = new AbortController(); + const promise = sleep(Duration.fromObject({ seconds: 10 }), ac.signal); + ac.abort(); + await promise; + expect(true).toBe(true); + }, 1_000); + }); + + describe('delegateAbortController', () => { + it('inherits parent abort state', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + parent.abort(); + expect(parent.signal.aborted).toBe(true); + expect(child.signal.aborted).toBe(true); + }); + + it('does not inherit from child to parent', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + child.abort(); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(true); + }); + }); +}); diff --git a/packages/backend-tasks/src/tasks/util.ts b/packages/backend-tasks/src/tasks/util.ts new file mode 100644 index 0000000000..0509f29363 --- /dev/null +++ b/packages/backend-tasks/src/tasks/util.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { Knex } from 'knex'; +import { DateTime, Duration } from 'luxon'; +import { AbortController, AbortSignal } from 'node-abort-controller'; + +// Keep the IDs compatible with e.g. Prometheus +export function validateId(id: string) { + if (typeof id !== 'string' || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(id)) { + throw new InputError( + `${id} is not a valid ID, expected string of lowercase characters and digits separated by underscores`, + ); + } +} + +export function dbTime(t: Date | string): DateTime { + if (typeof t === 'string') { + return DateTime.fromSQL(t); + } + return DateTime.fromJSDate(t); +} + +export function nowPlus(duration: Duration | undefined, knex: Knex) { + const seconds = duration?.as('seconds') ?? 0; + if (!seconds) { + return knex.fn.now(); + } + return knex.client.config.client === 'sqlite3' + ? knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]) + : knex.raw(`now() + interval '${seconds} seconds'`); +} + +/** + * Sleep for the given duration, but return sooner if the abort signal + * triggers. + * + * @param duration - The amount of time to sleep, at most + * @param abortSignal - An optional abort signal that short circuits the wait + */ +export async function sleep( + duration: Duration, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + return; + } + + await new Promise(resolve => { + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + + const done = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + abortSignal?.removeEventListener('abort', done); + resolve(); + }; + + timeoutHandle = setTimeout(done, duration.as('milliseconds')); + abortSignal?.addEventListener('abort', done); + }); +} + +/** + * Creates a new AbortController that, in addition to working as a regular + * standalone controller, also gets aborted if the given parent signal + * reaches aborted state. + * + * @param parent - The "parent" signal that can trigger the delegate + */ +export function delegateAbortController(parent?: AbortSignal): AbortController { + const delegate = new AbortController(); + + if (parent) { + if (parent.aborted) { + delegate.abort(); + } else { + const onParentAborted = () => { + delegate.abort(); + }; + + const onChildAborted = () => { + parent.removeEventListener('abort', onParentAborted); + }; + + parent.addEventListener('abort', onParentAborted, { once: true }); + delegate.signal.addEventListener('abort', onChildAborted, { once: true }); + } + } + + return delegate; +} diff --git a/packages/backend-test-utils/.snyk b/packages/backend-test-utils/.snyk new file mode 100644 index 0000000000..ad62599e66 --- /dev/null +++ b/packages/backend-test-utils/.snyk @@ -0,0 +1,47 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-TAR-1579155: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579152: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579147: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536758: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536531: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536528: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z +patch: {} diff --git a/packages/backend/.snyk b/packages/backend/.snyk new file mode 100644 index 0000000000..ad62599e66 --- /dev/null +++ b/packages/backend/.snyk @@ -0,0 +1,47 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-TAR-1579155: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579152: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579147: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536758: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536531: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536528: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z +patch: {} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ffdce949b7..f978e84da9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -48,12 +48,12 @@ import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; import search from './plugins/search'; import techdocs from './plugins/techdocs'; +import techInsights from './plugins/techInsights'; import todo from './plugins/todo'; import graphql from './plugins/graphql'; import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; -import techInsights from './plugins/techInsights'; import { PluginEnvironment } from './types'; function makeCreateEnv(config: Config) { diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index efdaacc1e0..9caac50c90 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.5", - "@backstage/errors": "^0.1.3", + "@backstage/errors": "^0.1.4", "cross-fetch": "^3.0.6" }, "devDependencies": { diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index cf9e873a66..863728b8f8 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -32,6 +32,23 @@ export const transforms = (options: TransformOptions): Transforms => { const extraTransforms = isDev ? ['react-hot-loader'] : []; + // This ensures that styles inserted from the style-loader and any + // async style chunks are always given lower priority than JSS styles. + // Note that this function is stringified and executed in the browser + // after transpilation, so stick to simple syntax + function insertBeforeJssStyles(element: any) { + const head = document.head; + // This makes sure that any style elements we insert get put before the + // dynamic styles from JSS, such as the ones from `makeStyles()`. + // TODO(Rugvip): This will likely break in material-ui v5, keep an eye on it. + const firstJssNode = head.querySelector('style[data-jss]'); + if (!firstJssNode) { + head.appendChild(element); + } else { + head.insertBefore(element, firstJssNode); + } + } + const loaders = [ { test: /\.(tsx?)$/, @@ -112,7 +129,14 @@ export const transforms = (options: TransformOptions): Transforms => { { test: /\.css$/i, use: [ - isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader, + isDev + ? { + loader: require.resolve('style-loader'), + options: { + insert: insertBeforeJssStyles, + }, + } + : MiniCssExtractPlugin.loader, { loader: require.resolve('css-loader'), options: { @@ -132,6 +156,7 @@ export const transforms = (options: TransformOptions): Transforms => { new MiniCssExtractPlugin({ filename: 'static/[name].[contenthash:8].css', chunkFilename: 'static/[name].[id].[contenthash:8].css', + insert: insertBeforeJssStyles, // Only applies to async chunks }), ); } diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 8fb3d5c788..d98b5ef5f8 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -45,6 +45,7 @@ import { Observable } from '@backstage/types'; import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { OptionalAppOptions } from '@backstage/app-defaults'; import { PendingAuthRequest } from '@backstage/core-plugin-api'; import { PluginOutput } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; @@ -150,7 +151,7 @@ export type AppComponents = { Progress: ComponentType<{}>; Router: ComponentType<{}>; ErrorBoundaryFallback: ComponentType; - ThemeProvider: ComponentType<{}>; + ThemeProvider?: ComponentType<{}>; SignInPage?: ComponentType; }; @@ -164,15 +165,41 @@ export type AppContext = { getComponents(): AppComponents; }; +// @public +export type AppIcons = { + 'kind:api': IconComponent; + 'kind:component': IconComponent; + 'kind:domain': IconComponent; + 'kind:group': IconComponent; + 'kind:location': IconComponent; + 'kind:system': IconComponent; + 'kind:user': IconComponent; + brokenImage: IconComponent; + catalog: IconComponent; + chat: IconComponent; + dashboard: IconComponent; + docs: IconComponent; + email: IconComponent; + github: IconComponent; + group: IconComponent; + help: IconComponent; + scaffolder: IconComponent; + search: IconComponent; + techdocs: IconComponent; + user: IconComponent; + warning: IconComponent; +}; + // @public export type AppOptions = { apis?: Iterable; - icons?: Partial & { + defaultApis?: Iterable; + icons: AppIcons & { [key in string]: IconComponent; }; plugins?: BackstagePluginWithAnyOutput[]; - components?: Partial; - themes?: AppTheme[]; + components: AppComponents; + themes: (Partial & Omit)[]; configLoader?: AppConfigLoader; bindRoutes?(context: { bind: AppRouteBinder }): void; }; @@ -286,10 +313,11 @@ export type BootErrorPageProps = { export { ConfigReader }; -// Warning: (ae-forgotten-export) The symbol "PrivateAppImpl" needs to be exported by the entry point index.d.ts -// +// @public @deprecated +export function createApp(options?: OptionalAppOptions): BackstageApp; + // @public -export function createApp(options?: AppOptions): PrivateAppImpl; +export function createSpecializedApp(options: AppOptions): BackstageApp; // @public export const defaultConfigLoader: AppConfigLoader; @@ -608,5 +636,4 @@ export class WebStorage implements StorageApi { // Warnings were encountered during analysis: // // src/apis/system/ApiProvider.d.ts:15:5 - (ae-forgotten-export) The symbol "ApiProviderProps" needs to be exported by the entry point index.d.ts -// src/app/types.d.ts:152:5 - (ae-forgotten-export) The symbol "AppIcons" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index a0877cf933..fc6c103109 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -29,6 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/app-defaults": "^0.1.0", "@backstage/core-components": "^0.7.3", "@backstage/config": "^0.1.11", "@backstage/core-plugin-api": "^0.1.13", diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts index d0f137f0be..76cf01d20a 100644 --- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts @@ -15,7 +15,6 @@ */ import MockOAuthApi from './MockOAuthApi'; -import PowerIcon from '@material-ui/icons/Power'; describe('MockOAuthApi', () => { it('should trigger all requests', async () => { @@ -24,13 +23,13 @@ describe('MockOAuthApi', () => { const authHandler1 = jest.fn().mockImplementation(() => authResult); const requester1 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler1, }); const authHandler2 = jest.fn().mockResolvedValue('other'); const requester2 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler2, }); @@ -67,13 +66,13 @@ describe('MockOAuthApi', () => { const authHandler1 = jest.fn(); const requester1 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler1, }); const authHandler2 = jest.fn(); const requester2 = mock.createAuthRequester({ - provider: { icon: PowerIcon, title: 'Test' }, + provider: { icon: () => null, title: 'Test' }, onAuthRequest: authHandler2, }); diff --git a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts index d8faf90229..3110197ad9 100644 --- a/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts +++ b/packages/core-app-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import ProviderIcon from '@material-ui/icons/AcUnit'; import { OAuthRequestManager } from './OAuthRequestManager'; describe('OAuthRequestManager', () => { @@ -27,7 +26,7 @@ describe('OAuthRequestManager', () => { const requester = manager.createAuthRequester({ provider: { title: 'My Provider', - icon: ProviderIcon, + icon: () => null, }, onAuthRequest: async () => 'hello', }); diff --git a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts index 227d70e494..caad40ddbf 100644 --- a/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/atlassian/AtlassianAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import AtlassianIcon from '@material-ui/icons/AcUnit'; import { atlassianAuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'atlassian', title: 'Atlassian', - icon: AtlassianIcon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts index 69462942e4..0a158ccb9e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import Auth0Icon from '@material-ui/icons/AcUnit'; import { auth0AuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'auth0', title: 'Auth0', - icon: Auth0Icon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts index c9db93906a..e488580c4d 100644 --- a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import BitbucketIcon from '@material-ui/icons/FormatBold'; import { BackstageIdentity, bitbucketAuthApiRef, @@ -37,7 +36,7 @@ export type BitbucketAuthResponse = { const DEFAULT_PROVIDER = { id: 'bitbucket', title: 'Bitbucket', - icon: BitbucketIcon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts index 01e7e77b8e..885e80da7c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import GithubIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GithubSession } from './types'; import { @@ -48,7 +47,7 @@ export type GithubAuthResponse = { const DEFAULT_PROVIDER = { id: 'github', title: 'GitHub', - icon: GithubIcon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index af581d0018..f46f2ef72e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import GitlabIcon from '@material-ui/icons/AcUnit'; import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'gitlab', title: 'GitLab', - icon: GitlabIcon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts index 916c52382e..a6f37b250f 100644 --- a/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import GoogleIcon from '@material-ui/icons/AcUnit'; import { googleAuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'google', title: 'Google', - icon: GoogleIcon, + icon: () => null, }; const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index 09bd7f67a2..148be66873 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import MicrosoftIcon from '@material-ui/icons/AcUnit'; import { microsoftAuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'microsoft', title: 'Microsoft', - icon: MicrosoftIcon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 6c0759d967..45937a68f8 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import OAuth2Icon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; @@ -51,7 +50,7 @@ export type OAuth2Response = { const DEFAULT_PROVIDER = { id: 'oauth2', title: 'Your Identity Provider', - icon: OAuth2Icon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts index 294fb496af..465a124051 100644 --- a/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import OktaIcon from '@material-ui/icons/AcUnit'; import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'okta', title: 'Okta', - icon: OktaIcon, + icon: () => null, }; const OKTA_OIDC_SCOPES: Set = new Set([ diff --git a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts index f49ea4cde8..5f933b9c6e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import OneLoginIcon from '@material-ui/icons/AcUnit'; import { oneloginAuthApiRef, OAuthRequestApi, @@ -33,7 +32,7 @@ type CreateOptions = { const DEFAULT_PROVIDER = { id: 'onelogin', title: 'onelogin', - icon: OneLoginIcon, + icon: () => null, }; const OIDC_SCOPES: Set = new Set([ diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index f2668dedd2..63985979de 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import SamlIcon from '@material-ui/icons/AcUnit'; import { DirectAuthConnector } from '../../../../lib/AuthConnector'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { @@ -42,7 +41,7 @@ export type SamlAuthResponse = { const DEFAULT_PROVIDER = { id: 'saml', title: 'SAML', - icon: SamlIcon, + icon: () => null, }; /** diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx similarity index 87% rename from packages/core-app-api/src/app/App.test.tsx rename to packages/core-app-api/src/app/AppManager.test.tsx index 6ed5d45b86..07401660e8 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -20,11 +20,9 @@ import { renderWithEffects, withLogCollector, } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; import { render, screen } from '@testing-library/react'; import React, { PropsWithChildren } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { defaultAppIcons } from './icons'; import { configApiRef, createApiFactory, @@ -37,8 +35,8 @@ import { createRoutableExtension, analyticsApiRef, } from '@backstage/core-plugin-api'; -import { generateBoundRoutes, PrivateAppImpl } from './App'; -import { AppThemeProvider } from './AppThemeProvider'; +import { generateBoundRoutes, AppManager } from './AppManager'; +import { AppComponents, AppIcons } from './types'; describe('generateBoundRoutes', () => { it('runs happy path', () => { @@ -128,7 +126,7 @@ describe('Integration Test', () => { const HiddenComponent = plugin2.provide( createRoutableExtension({ name: 'HiddenComponent', - component: () => Promise.resolve((_: { path?: string }) =>
), + component: () => Promise.resolve(() =>
), mountPoint: plugin2RouteRef, }), ); @@ -137,7 +135,7 @@ describe('Integration Test', () => { createRoutableExtension({ name: 'ExposedComponent', component: () => - Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + Promise.resolve(() => { const link1 = useRouteRef(plugin1RouteRef); const link2 = useRouteRef(plugin2RouteRef); const subLink1 = useRouteRef(subRouteRef1); @@ -178,17 +176,19 @@ describe('Integration Test', () => { }), ); - const components = { + const components: AppComponents = { NotFoundErrorPage: () => null, BootErrorPage: () => null, Progress: () => null, Router: BrowserRouter, ErrorBoundaryFallback: () => null, - ThemeProvider: AppThemeProvider, + ThemeProvider: ({ children }) => <>{children}, }; + const icons = {} as AppIcons; + it('runs happy paths', async () => { - const app = new PrivateAppImpl({ + const app = new AppManager({ apis: [noOpAnalyticsApi], defaultApis: [], themes: [ @@ -196,12 +196,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -219,8 +220,8 @@ describe('Integration Test', () => { - - + } /> + } /> , @@ -242,7 +243,7 @@ describe('Integration Test', () => { }); it('runs happy paths without optional routes', async () => { - const app = new PrivateAppImpl({ + const app = new AppManager({ apis: [noOpAnalyticsApi], defaultApis: [], themes: [ @@ -250,12 +251,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -271,8 +273,8 @@ describe('Integration Test', () => { - - + } /> + } /> , @@ -299,7 +301,7 @@ describe('Integration Test', () => { }), ]; - const app = new PrivateAppImpl({ + const app = new AppManager({ apis, defaultApis: [], themes: [ @@ -307,10 +309,10 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [ createPlugin({ id: 'test', @@ -318,6 +320,7 @@ describe('Integration Test', () => { }), ], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -333,8 +336,8 @@ describe('Integration Test', () => { - - + } /> + } /> , @@ -349,7 +352,7 @@ describe('Integration Test', () => { it('should track route changes via analytics api', async () => { const mockAnalyticsApi = new MockAnalyticsApi(); const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)]; - const app = new PrivateAppImpl({ + const app = new AppManager({ apis, defaultApis: [], themes: [ @@ -357,12 +360,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -379,7 +383,7 @@ describe('Integration Test', () => { } /> - } /> + } /> , @@ -409,7 +413,7 @@ describe('Integration Test', () => { }); it('should throw some error when the route has duplicate params', () => { - const app = new PrivateAppImpl({ + const app = new AppManager({ apis: [], defaultApis: [], themes: [ @@ -417,12 +421,13 @@ describe('Integration Test', () => { id: 'light', title: 'Light Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => <>{children}, }, ], - icons: defaultAppIcons, + icons, plugins: [], components, + configLoader: async () => [], bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { extRouteRef1: plugin1RouteRef, @@ -439,9 +444,9 @@ describe('Integration Test', () => { - - - + }> + } /> + , diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/AppManager.tsx similarity index 94% rename from packages/core-app-api/src/app/App.tsx rename to packages/core-app-api/src/app/AppManager.tsx index 8cb3f167fb..950f57bb7d 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -77,6 +77,8 @@ import { SignInPageProps, SignInResult, } from './types'; +import { AppThemeProvider } from './AppThemeProvider'; +import { defaultConfigLoader } from './defaultConfigLoader'; export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) { const result = new Map(); @@ -121,17 +123,6 @@ function getBasePath(configApi: Config) { return pathname; } -type FullAppOptions = { - apis: Iterable; - icons: NonNullable; - plugins: BackstagePlugin[]; - components: AppComponents; - themes: AppTheme[]; - configLoader?: AppConfigLoader; - defaultApis: Iterable; - bindRoutes?: AppOptions['bindRoutes']; -}; - function useConfigLoader( configLoader: AppConfigLoader | undefined, components: AppComponents, @@ -151,7 +142,7 @@ function useConfigLoader( noConfigNode = ; } - const { ThemeProvider } = components; + const { ThemeProvider = AppThemeProvider } = components; // Before the config is loaded we can't use a router, so exit early if (noConfigNode) { @@ -170,7 +161,7 @@ function useConfigLoader( } class AppContextImpl implements AppContext { - constructor(private readonly app: PrivateAppImpl) {} + constructor(private readonly app: AppManager) {} getPlugins(): BackstagePlugin[] { return this.app.getPlugins(); @@ -185,7 +176,7 @@ class AppContextImpl implements AppContext { } } -export class PrivateAppImpl implements BackstageApp { +export class AppManager implements BackstageApp { private apiHolder?: ApiHolder; private configApi?: ConfigApi; @@ -201,14 +192,16 @@ export class PrivateAppImpl implements BackstageApp { private readonly identityApi = new AppIdentity(); private readonly apiFactoryRegistry: ApiFactoryRegistry; - constructor(options: FullAppOptions) { - this.apis = options.apis; + constructor(options: AppOptions) { + this.apis = options.apis ?? []; this.icons = options.icons; - this.plugins = new Set(options.plugins); + this.plugins = new Set( + (options.plugins as BackstagePlugin[]) ?? [], + ); this.components = options.components; - this.themes = options.themes; - this.configLoader = options.configLoader; - this.defaultApis = options.defaultApis; + this.themes = options.themes as AppTheme[]; + this.configLoader = options.configLoader ?? defaultConfigLoader; + this.defaultApis = options.defaultApis ?? []; this.bindRoutes = options.bindRoutes; this.apiFactoryRegistry = new ApiFactoryRegistry(); } @@ -307,7 +300,7 @@ export class PrivateAppImpl implements BackstageApp { return loadedConfig.node; } - const { ThemeProvider } = this.components; + const { ThemeProvider = AppThemeProvider } = this.components; return ( diff --git a/packages/core-app-api/src/app/AppThemeProvider.tsx b/packages/core-app-api/src/app/AppThemeProvider.tsx index 4e3a8487a6..2e145025d5 100644 --- a/packages/core-app-api/src/app/AppThemeProvider.tsx +++ b/packages/core-app-api/src/app/AppThemeProvider.tsx @@ -90,6 +90,17 @@ export function AppThemeProvider({ children }: PropsWithChildren<{}>) { throw new Error('App has no themes'); } + if (appTheme.Provider) { + return ; + } + + // eslint-disable-next-line no-console + console.warn( + "DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " + + 'and should be migrated to use a Provider instead. ' + + 'See https://backstage.io/docs/api/deprecations#app-theme for more info.', + ); + return ( {children} diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx index 65aa15b50c..e11076099b 100644 --- a/packages/core-app-api/src/app/createApp.tsx +++ b/packages/core-app-api/src/app/createApp.tsx @@ -14,171 +14,25 @@ * limitations under the License. */ -import { AppConfig } from '@backstage/config'; -import { JsonObject } from '@backstage/types'; -import { Button } from '@material-ui/core'; -import { ErrorPage, ErrorPanel, Progress } from '@backstage/core-components'; -import { darkTheme, lightTheme } from '@backstage/theme'; -import DarkIcon from '@material-ui/icons/Brightness2'; -import LightIcon from '@material-ui/icons/WbSunny'; -import React, { PropsWithChildren } from 'react'; import { - BrowserRouter, - MemoryRouter, - useInRouterContext, -} from 'react-router-dom'; -import { PrivateAppImpl } from './App'; -import { AppThemeProvider } from './AppThemeProvider'; -import { defaultApis } from './defaultApis'; -import { defaultAppIcons } from './icons'; -import { - AppConfigLoader, - AppOptions, - BootErrorPageProps, - ErrorBoundaryFallbackProps, -} from './types'; -import { BackstagePlugin } from '@backstage/core-plugin-api'; - -/** - * The default config loader, which expects that config is available at compile-time - * in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as - * returned by the config loader. - * - * It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string, - * which can be rewritten at runtime to contain an additional JSON config object. - * If runtime config is present, it will be placed first in the config array, overriding - * other config values. - * - * @public - */ -export const defaultConfigLoader: AppConfigLoader = async ( - // This string may be replaced at runtime to provide additional config. - // It should be replaced by a JSON-serialized config object. - // It's a param so we can test it, but at runtime this will always fall back to default. - runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__', -) => { - const appConfig = process.env.APP_CONFIG; - if (!appConfig) { - throw new Error('No static configuration provided'); - } - if (!Array.isArray(appConfig)) { - throw new Error('Static configuration has invalid format'); - } - const configs = appConfig.slice() as unknown as AppConfig[]; - - // Avoiding this string also being replaced at runtime - if ( - runtimeConfigJson !== - '__app_injected_runtime_config__'.toLocaleUpperCase('en-US') - ) { - try { - const data = JSON.parse(runtimeConfigJson) as JsonObject; - if (Array.isArray(data)) { - configs.push(...data); - } else { - configs.push({ data, context: 'env' }); - } - } catch (error) { - throw new Error(`Failed to load runtime configuration, ${error}`); - } - } - - const windowAppConfig = (window as any).__APP_CONFIG__; - if (windowAppConfig) { - configs.push({ - context: 'window', - data: windowAppConfig, - }); - } - return configs; -}; - -export function OptionallyWrapInRouter({ children }: PropsWithChildren<{}>) { - if (useInRouterContext()) { - return <>{children}; - } - return {children}; -} + createApp as createDefaultApp, + OptionalAppOptions, +} from '@backstage/app-defaults'; /** * Creates a new Backstage App. * + * @deprecated Use {@link @backstage/app-defaults#createApp} from `@backstage/app-defaults` instead + * @param options - A set of options for creating the app * @public */ -export function createApp(options?: AppOptions) { - const DefaultNotFoundPage = () => ( - +export function createApp(options?: OptionalAppOptions) { + // eslint-disable-next-line no-console + console.warn( + 'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' + + 'migrate to importing createApp from the @backstage/app-defaults package instead. ' + + 'If you do not wish to use a standard app configuration but instead supply all options yourself ' + + ' you can use createSpecializedApp from @backstage/core-app-api instead.', ); - const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => { - let message = ''; - if (step === 'load-config') { - message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; - } else if (step === 'load-chunk') { - message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`; - } - // TODO: figure out a nicer way to handle routing on the error page, when it can be done. - return ( - - - - ); - }; - const DefaultErrorBoundaryFallback = ({ - error, - resetError, - plugin, - }: ErrorBoundaryFallbackProps) => { - return ( - - - - ); - }; - - const apis = options?.apis ?? []; - const icons = { ...defaultAppIcons, ...options?.icons }; - const plugins = options?.plugins ?? []; - const components = { - NotFoundErrorPage: DefaultNotFoundPage, - BootErrorPage: DefaultBootErrorPage, - Progress: Progress, - Router: BrowserRouter, - ErrorBoundaryFallback: DefaultErrorBoundaryFallback, - ThemeProvider: AppThemeProvider, - ...options?.components, - }; - const themes = options?.themes ?? [ - { - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: lightTheme, - icon: , - }, - { - id: 'dark', - title: 'Dark Theme', - variant: 'dark', - theme: darkTheme, - icon: , - }, - ]; - const configLoader = options?.configLoader ?? defaultConfigLoader; - - return new PrivateAppImpl({ - apis, - icons, - plugins: plugins as BackstagePlugin[], - components, - themes, - configLoader, - defaultApis, - bindRoutes: options?.bindRoutes, - }); + return createDefaultApp(options); } diff --git a/packages/core-app-api/src/app/createSpecializedApp.tsx b/packages/core-app-api/src/app/createSpecializedApp.tsx new file mode 100644 index 0000000000..9bda223722 --- /dev/null +++ b/packages/core-app-api/src/app/createSpecializedApp.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppManager } from './AppManager'; +import { AppOptions, BackstageApp } from './types'; + +/** + * Creates a new Backstage App where the full set of options are required. + * + * @public + * @param options - A set of options for creating the app + * @returns + */ +export function createSpecializedApp(options: AppOptions): BackstageApp { + return new AppManager(options); +} diff --git a/packages/core-app-api/src/app/createApp.test.tsx b/packages/core-app-api/src/app/defaultConfigLoader.test.ts similarity index 66% rename from packages/core-app-api/src/app/createApp.test.tsx rename to packages/core-app-api/src/app/defaultConfigLoader.test.ts index 0c3f62b14d..aebba92c43 100644 --- a/packages/core-app-api/src/app/createApp.test.tsx +++ b/packages/core-app-api/src/app/defaultConfigLoader.test.ts @@ -14,15 +14,7 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; -import { renderWithEffects } from '@backstage/test-utils'; -import React, { PropsWithChildren } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { - defaultConfigLoader, - OptionallyWrapInRouter, - createApp, -} from './createApp'; +import { defaultConfigLoader } from './defaultConfigLoader'; (process as any).env = { NODE_ENV: 'test' }; const anyEnv = process.env as any; @@ -100,44 +92,3 @@ describe('defaultConfigLoader', () => { ]); }); }); - -describe('OptionallyWrapInRouter', () => { - it('should wrap with router if not yet inside a router', async () => { - const { getByText } = render( - Test, - ); - - expect(getByText('Test')).toBeInTheDocument(); - }); - - it('should not wrap with router if already inside a router', async () => { - const { getByText } = render( - - Test - , - ); - - expect(getByText('Test')).toBeInTheDocument(); - }); -}); - -describe('Optional ThemeProvider', () => { - it('should render app with user-provided ThemeProvider', async () => { - const components = { - NotFoundErrorPage: () => null, - BootErrorPage: () => null, - Progress: () => null, - Router: MemoryRouter, - ErrorBoundaryFallback: () => null, - ThemeProvider: ({ children }: PropsWithChildren<{}>) => ( -
{children}
- ), - }; - - const App = createApp({ components }).getProvider(); - - await renderWithEffects(); - - expect(screen.getByRole('main')).toBeInTheDocument(); - }); -}); diff --git a/packages/core-app-api/src/app/defaultConfigLoader.ts b/packages/core-app-api/src/app/defaultConfigLoader.ts new file mode 100644 index 0000000000..00aee2d367 --- /dev/null +++ b/packages/core-app-api/src/app/defaultConfigLoader.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppConfig } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { AppConfigLoader } from './types'; + +/** + * The default config loader, which expects that config is available at compile-time + * in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as + * returned by the config loader. + * + * It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string, + * which can be rewritten at runtime to contain an additional JSON config object. + * If runtime config is present, it will be placed first in the config array, overriding + * other config values. + * + * @public + */ +export const defaultConfigLoader: AppConfigLoader = async ( + // This string may be replaced at runtime to provide additional config. + // It should be replaced by a JSON-serialized config object. + // It's a param so we can test it, but at runtime this will always fall back to default. + runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__', +) => { + const appConfig = process.env.APP_CONFIG; + if (!appConfig) { + throw new Error('No static configuration provided'); + } + if (!Array.isArray(appConfig)) { + throw new Error('Static configuration has invalid format'); + } + const configs = appConfig.slice() as unknown as AppConfig[]; + + // Avoiding this string also being replaced at runtime + if ( + runtimeConfigJson !== + '__app_injected_runtime_config__'.toLocaleUpperCase('en-US') + ) { + try { + const data = JSON.parse(runtimeConfigJson) as JsonObject; + if (Array.isArray(data)) { + configs.push(...data); + } else { + configs.push({ data, context: 'env' }); + } + } catch (error) { + throw new Error(`Failed to load runtime configuration, ${error}`); + } + } + + const windowAppConfig = (window as any).__APP_CONFIG__; + if (windowAppConfig) { + configs.push({ + context: 'window', + data: windowAppConfig, + }); + } + return configs; +}; diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index ab82774cad..5ea5405632 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,5 +14,7 @@ * limitations under the License. */ -export { createApp, defaultConfigLoader } from './createApp'; +export { createApp } from './createApp'; +export { createSpecializedApp } from './createSpecializedApp'; +export { defaultConfigLoader } from './defaultConfigLoader'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 407018111b..f2d1976f87 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -27,7 +27,6 @@ import { PluginOutput, } from '@backstage/core-plugin-api'; import { AppConfig } from '@backstage/config'; -import { AppIcons } from './icons'; /** * Props for the `BootErrorPage` component of {@link AppComponents}. @@ -97,7 +96,7 @@ export type AppComponents = { Progress: ComponentType<{}>; Router: ComponentType<{}>; ErrorBoundaryFallback: ComponentType; - ThemeProvider: ComponentType<{}>; + ThemeProvider?: ComponentType<{}>; /** * An optional sign-in page that will be rendered instead of the AppRouter at startup. @@ -111,6 +110,36 @@ export type AppComponents = { SignInPage?: ComponentType; }; +/** + * A set of well-known icons that should be available within an app. + * + * @public + */ +export type AppIcons = { + 'kind:api': IconComponent; + 'kind:component': IconComponent; + 'kind:domain': IconComponent; + 'kind:group': IconComponent; + 'kind:location': IconComponent; + 'kind:system': IconComponent; + 'kind:user': IconComponent; + + brokenImage: IconComponent; + catalog: IconComponent; + chat: IconComponent; + dashboard: IconComponent; + docs: IconComponent; + email: IconComponent; + github: IconComponent; + group: IconComponent; + help: IconComponent; + scaffolder: IconComponent; + search: IconComponent; + techdocs: IconComponent; + user: IconComponent; + warning: IconComponent; +}; + /** * A function that loads in the App config that will be accessible via the ConfigApi. * @@ -196,14 +225,23 @@ export type BackstagePluginWithAnyOutput = Omit< export type AppOptions = { /** * A collection of ApiFactories to register in the application to either - * add add new ones, or override factories provided by default or by plugins. + * add new ones, or override factories provided by default or by plugins. */ apis?: Iterable; + /** + * A collection of ApiFactories to register in the application as default APIs. + * Theses APIs can not be overridden by plugin factories, but can be overridden + * by plugin APIs provided through the + * A collection of ApiFactories to register in the application to either + * add new ones, or override factories provided by default or by plugins. + */ + defaultApis?: Iterable; + /** * Supply icons to override the default ones. */ - icons?: Partial & { [key in string]: IconComponent }; + icons: AppIcons & { [key in string]: IconComponent }; /** * A list of all plugins to include in the app. @@ -213,7 +251,7 @@ export type AppOptions = { /** * Supply components to the app to override the default ones. */ - components?: Partial; + components: AppComponents; /** * Themes provided as a part of the app. By default two themes are included, one @@ -226,18 +264,26 @@ export type AppOptions = { * id: 'light', * title: 'Light Theme', * variant: 'light', - * theme: lightTheme, * icon: , + * Provider: ({ children }) => ( + * + * {children} + * + * ), * }, { * id: 'dark', * title: 'Dark Theme', * variant: 'dark', - * theme: darkTheme, * icon: , + * Provider: ({ children }) => ( + * + * {children} + * + * ), * }] * ``` */ - themes?: AppTheme[]; + themes: (Partial & Omit)[]; /** * A function that loads in App configuration that will be accessible via diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 7e448586e3..baf89a59d8 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import ProviderIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from './DefaultAuthConnector'; import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; import * as loginPopup from '../loginPopup'; @@ -29,7 +28,7 @@ const defaultOptions = { provider: { id: 'my-provider', title: 'My Provider', - icon: ProviderIcon, + icon: () => null, }, oauthRequestApi: new MockOAuthApi(), sessionTransform: ({ expiresInSeconds, ...res }: any) => ({ diff --git a/packages/core-components/src/layout/Page/Page.stories.tsx b/packages/core-components/src/layout/Page/Page.stories.tsx index 7386d198a3..12ddd51d04 100644 --- a/packages/core-components/src/layout/Page/Page.stories.tsx +++ b/packages/core-components/src/layout/Page/Page.stories.tsx @@ -20,8 +20,7 @@ import Grid from '@material-ui/core/Grid'; import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; import React, { useState } from 'react'; -import { MemoryRouter } from 'react-router'; -import { createApp } from '@backstage/core-app-api'; +import { wrapInTestApp } from '@backstage/test-utils'; import { Content, ContentHeader, @@ -197,53 +196,44 @@ const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => ( ); -const app = createApp({ configLoader: async () => [] }); -const AppProvider = app.getProvider(); - export const PluginWithData = () => { const [selectedTab, setSelectedTab] = useState(2); - return ( - - -
- - - setSelectedTab(index)} - tabs={tabs.map(({ label }, index) => ({ - id: index.toString(), - label, - }))} - /> - - - - - -
-
-
- ); + return wrapInTestApp(() => ( +
+ + + setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + + + + +
+ )); }; export const PluginWithTable = () => { - return ( - -
- - - - - - - - - - ); + return wrapInTestApp(() => ( +
+ + + + +
+ + + + )); }; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index f0cee2a42c..2765b02bca 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -5,11 +5,14 @@ ```ts /// +import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; +import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api'; import { Observable as Observable_2 } from '@backstage/types'; import { Observer as Observer_2 } from '@backstage/types'; +import { ProfileInfo as ProfileInfo_2 } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; @@ -161,13 +164,14 @@ export type AppComponents = { Progress: ComponentType<{}>; Router: ComponentType<{}>; ErrorBoundaryFallback: ComponentType; + ThemeProvider?: ComponentType<{}>; SignInPage?: ComponentType; }; // @public export type AppContext = { - getPlugins(): BackstagePlugin[]; - getSystemIcon(key: string): IconComponent | undefined; + getPlugins(): BackstagePlugin_2[]; + getSystemIcon(key: string): IconComponent_2 | undefined; getComponents(): AppComponents; }; @@ -178,6 +182,7 @@ export type AppTheme = { variant: 'light' | 'dark'; theme: BackstageTheme; icon?: React.ReactElement; + Provider?(props: { children: ReactNode }): JSX.Element | null; }; // @public @@ -421,7 +426,7 @@ export const errorApiRef: ApiRef; // @public export type ErrorBoundaryFallbackProps = { - plugin?: BackstagePlugin; + plugin?: BackstagePlugin_2; error: Error; resetError: () => void; }; @@ -741,7 +746,7 @@ export type SignInPageProps = { // @public export type SignInResult = { userId: string; - profile: ProfileInfo; + profile: ProfileInfo_2; getIdToken?: () => Promise; signOut?: () => Promise; }; diff --git a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts index de523663f2..8053c449a1 100644 --- a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ReactNode } from 'react'; import { ApiRef, createApiRef } from '../system'; import { BackstageTheme } from '@backstage/theme'; import { Observable } from '@backstage/types'; @@ -41,6 +42,7 @@ export type AppTheme = { /** * The specialized MaterialUI theme instance. + * @deprecated use Provider instead, see https://backstage.io/docs/api/deprecations#app-theme */ theme: BackstageTheme; @@ -48,6 +50,8 @@ export type AppTheme = { * An Icon for the theme mode setting. */ icon?: React.ReactElement; + + Provider?(props: { children: ReactNode }): JSX.Element | null; }; /** diff --git a/packages/core-plugin-api/src/app/types.ts b/packages/core-plugin-api/src/app/types.ts index d072f0a2e6..df78728a21 100644 --- a/packages/core-plugin-api/src/app/types.ts +++ b/packages/core-plugin-api/src/app/types.ts @@ -14,110 +14,15 @@ * limitations under the License. */ -import { ComponentType } from 'react'; -import { ProfileInfo } from '../apis/definitions'; -import { IconComponent } from '../icons'; -import { BackstagePlugin } from '../plugin/types'; - -/** - * Props for the BootErrorPage. - * - * @public - */ -export type BootErrorPageProps = { - step: 'load-config' | 'load-chunk'; - error: Error; -}; - -/** - * Data and handlers associated with the user sign in event. - * - * @public - */ -export type SignInResult = { - /** - * User ID that will be returned by the IdentityApi - */ - userId: string; - - profile: ProfileInfo; - - /** - * Function used to retrieve an ID token for the signed in user. - */ - getIdToken?: () => Promise; - - /** - * Sign out handler that will be called if the user requests to sign out. - */ - signOut?: () => Promise; -}; - -/** - * Props for the SignInPage. - * - * @public - */ -export type SignInPageProps = { - /** - * Set the sign-in result for the app. This should only be called once. - */ - onResult(result: SignInResult): void; -}; - -/** - * Props for the ErrorBoundaryFallback. - * - * @public - */ -export type ErrorBoundaryFallbackProps = { - plugin?: BackstagePlugin; - error: Error; - resetError: () => void; -}; - -/** - * Basic app components. - * - * @public - */ -export type AppComponents = { - NotFoundErrorPage: ComponentType<{}>; - BootErrorPage: ComponentType; - Progress: ComponentType<{}>; - Router: ComponentType<{}>; - ErrorBoundaryFallback: ComponentType; - - /** - * An optional sign-in page that will be rendered instead of the AppRouter at startup. - * - * If a sign-in page is set, it will always be shown before the app, and it is up - * to the sign-in page to handle e.g. saving of login methods for subsequent visits. - * - * The sign-in page will be displayed until it has passed up a result to the parent, - * and which point the AppRouter and all of its children will be rendered instead. - */ - SignInPage?: ComponentType; -}; - -/** - * Provides plugins and components registered in the app. - * - * @public - */ -export type AppContext = { - /** - * Get a list of all plugins that are installed in the app. - */ - getPlugins(): BackstagePlugin[]; - - /** - * Get a common or custom icon for this app. - */ - getSystemIcon(key: string): IconComponent | undefined; - - /** - * Get the components registered for various purposes in the app. - */ - getComponents(): AppComponents; -}; +// This is a bit of a hack that we use to avoid having to redeclare these types +// within this package or have an explicit dependency on core-app-api. +// These types end up being inlined and duplicated into this package at build time. +// eslint-disable-next-line no-restricted-imports +export type { + BootErrorPageProps, + SignInResult, + SignInPageProps, + ErrorBoundaryFallbackProps, + AppComponents, + AppContext, +} from '../../../core-app-api/src/app/types'; diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 97904cc15b..6c85652c64 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -30,6 +30,7 @@ Rollup will extract the value of the version field in each package at build time leaving any imports in place. */ +import { version as appDefaults } from '../../../app-defaults/package.json'; import { version as backendCommon } from '../../../backend-common/package.json'; import { version as catalogClient } from '../../../catalog-client/package.json'; import { version as catalogModel } from '../../../catalog-model/package.json'; @@ -68,6 +69,7 @@ import { version as pluginTechdocsBackend } from '../../../../plugins/techdocs-b import { version as pluginUserSettings } from '../../../../plugins/user-settings/package.json'; export const packageVersions = { + '@backstage/app-defaults': appDefaults, '@backstage/backend-common': backendCommon, '@backstage/catalog-client': catalogClient, '@backstage/catalog-model': catalogModel, 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 c443b45dc7..9e15608a01 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 @@ -4,6 +4,7 @@ "private": true, "bundled": true, "dependencies": { + "@backstage/app-defaults": "^{{version '@backstage/app-defaults'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", "@backstage/cli": "^{{version '@backstage/cli'}}", "@backstage/core-app-api": "^{{version '@backstage/core-app-api'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 4cd83685a6..8a535835b4 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -26,7 +26,8 @@ import { searchPage } from './components/search/SearchPage'; import { Root } from './components/Root'; import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; const app = createApp({ apis, diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index e01390e169..974329e8d4 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -29,6 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/app-defaults": "^0.1.0", "@backstage/core-app-api": "^0.1.18", "@backstage/core-components": "^0.7.1", "@backstage/core-plugin-api": "^0.1.11", @@ -44,10 +45,12 @@ "@testing-library/user-event": "^13.1.8", "@types/react": "*", "react": "^16.12.0", + "react-use": "^17.2.4", "react-dom": "^16.12.0", "react-hot-loader": "^4.12.21", "react-router": "6.0.0-beta.0", - "react-router-dom": "6.0.0-beta.0" + "react-router-dom": "6.0.0-beta.0", + "zen-observable": "^0.8.15" }, "devDependencies": { "@backstage/cli": "^0.8.0", diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx new file mode 100644 index 0000000000..3907dc1007 --- /dev/null +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api'; +import { renderInTestApp } from '@backstage/test-utils'; +import { BackstageTheme } from '@backstage/theme'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import ObservableImpl from 'zen-observable'; +import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; + +describe('SidebarThemeSwitcher', () => { + let appThemeApi: jest.Mocked; + let apiRegistry: ApiRegistry; + + beforeEach(() => { + appThemeApi = { + activeThemeId$: jest.fn(), + getActiveThemeId: jest.fn(), + getInstalledThemes: jest.fn(), + setActiveThemeId: jest.fn(), + }; + + appThemeApi.activeThemeId$.mockReturnValue( + ObservableImpl.of('dark'), + ); + appThemeApi.getInstalledThemes.mockReturnValue([ + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: {} as unknown as BackstageTheme, + }, + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: {} as unknown as BackstageTheme, + }, + ]); + + apiRegistry = ApiRegistry.with(appThemeApiRef, appThemeApi); + }); + + it('should display current theme', async () => { + const { getByLabelText, getByRole, getByText } = await renderInTestApp( + + + , + ); + + const button = getByLabelText('Switch Theme'); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(getByText('Dark Theme')).toBeInTheDocument(); + expect( + getByText('Dark Theme').parentElement?.parentElement, + ).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select different theme', async () => { + const { getByLabelText, getByRole, getByText } = await renderInTestApp( + + + , + ); + + const button = getByLabelText('Switch Theme'); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(getByRole('listbox')).toBeInTheDocument(); + + userEvent.click(getByText('Light Theme')); + + expect(appThemeApi.setActiveThemeId).toHaveBeenCalledWith('light'); + }); +}); diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx new file mode 100644 index 0000000000..e13554bbf7 --- /dev/null +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SidebarItem } from '@backstage/core-components'; +import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; +import { ListItemIcon, ListItemText, Menu, MenuItem } from '@material-ui/core'; +import AutoIcon from '@material-ui/icons/BrightnessAuto'; +import React, { cloneElement, useCallback, useState } from 'react'; +import { useObservable } from 'react-use'; + +type ThemeIconProps = { + active?: boolean; + icon: JSX.Element | undefined; +}; + +const ThemeIcon = ({ active, icon }: ThemeIconProps) => + icon ? ( + cloneElement(icon, { + color: active ? 'primary' : undefined, + }) + ) : ( + + ); + +export const SidebarThemeSwitcher = () => { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + const themeIds = appThemeApi.getInstalledThemes(); + const activeTheme = themeIds.find(t => t.id === themeId); + + const [anchorEl, setAnchorEl] = useState(); + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleSelectTheme = (newThemeId: string | undefined) => { + if (themeIds.some(t => t.id === newThemeId)) { + appThemeApi.setActiveThemeId(newThemeId); + } else { + appThemeApi.setActiveThemeId(undefined); + } + + setAnchorEl(undefined); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + const ActiveIcon = useCallback( + () => , + [activeTheme], + ); + + return ( + <> + + + + Choose a theme + handleSelectTheme(undefined)} + > + + + + Auto + + + {themeIds.map(theme => { + const active = theme.id === themeId; + return ( + handleSelectTheme(theme.id)} + > + + + + {theme.title} + + ); + })} + + + ); +}; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 755289a060..adea9e14f2 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -14,6 +14,30 @@ * limitations under the License. */ +import { createApp } from '@backstage/app-defaults'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { + AlertDisplay, + OAuthRequestDialog, + Sidebar, + SidebarDivider, + SidebarItem, + SidebarPage, + SidebarSpace, + SidebarSpacer, +} from '@backstage/core-components'; +import { + AnyApiFactory, + ApiFactory, + AppTheme, + attachComponentData, + BackstagePlugin, + configApiRef, + createApiFactory, + createRouteRef, + IconComponent, + RouteRef, +} from '@backstage/core-plugin-api'; import { ScmIntegrationsApi, scmIntegrationsApiRef, @@ -24,30 +48,7 @@ import React, { ComponentType, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import { hot } from 'react-hot-loader'; import { Route } from 'react-router'; - -import { - AlertDisplay, - OAuthRequestDialog, - Sidebar, - SidebarItem, - SidebarPage, - SidebarSpacer, -} from '@backstage/core-components'; - -import { - AnyApiFactory, - ApiFactory, - AppTheme, - attachComponentData, - configApiRef, - createApiFactory, - createRouteRef, - IconComponent, - RouteRef, - BackstagePlugin, -} from '@backstage/core-plugin-api'; - -import { createApp, FlatRoutes } from '@backstage/core-app-api'; +import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; const GatheringRoute: (props: { path: string; @@ -202,6 +203,9 @@ export class DevAppBuilder { {this.sidebarItems} + + + {this.routes} diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index 1b8e321edb..6c941cda58 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -25,7 +25,7 @@ }, "devDependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/errors": "^0.1.2", + "@backstage/errors": "^0.1.4", "@types/fs-extra": "^9.0.1", "@types/node": "^14.14.32", "@types/puppeteer": "^5.4.4", diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 5bdc6b3a2f..72ff42311d 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -1,23 +1,30 @@ const path = require('path'); const WebpackPluginFailBuildOnWarning = require('./webpack-plugin-fail-build-on-warning'); +/** + * This set of stories are the ones that we publish to backstage.io. + */ +const BACKSTAGE_CORE_STORIES = [ + 'packages/core-components', + 'plugins/org', + 'plugins/search', +]; + module.exports = ({ args }) => { // Calling storybook with no args causes our default list of stories to be used. // This set of stories are the ones that we publish to backstage.io // // If it's called with args, each arg should be the path to a package that we will // show the stories from, for example `yarn storybook plugins/catalog`. - let stories; - if (args.length === 0) { - stories = [ - '../../core-components/src/**/*.stories.tsx', - '../../../plugins/org/src/**/*.stories.tsx', - '../../../plugins/search/src/**/*.stories.tsx', - ]; - } else { - const rootDir = path.resolve(__dirname, '../../..'); - stories = args.map(arg => path.join(rootDir, arg, 'src/**/*.stories.tsx')); - } + + const rootPath = '../../..'; + const storiesSrcGlob = 'src/**/*.stories.tsx'; + + const getStoriesPath = package => + path.posix.join(rootPath, package, storiesSrcGlob); + + const packages = args.length === 0 ? BACKSTAGE_CORE_STORIES : args; + const stories = packages.map(getStoriesPath); return { stories, diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index ab78eab683..6f5f78de8c 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -34,6 +34,7 @@ "@backstage/theme": "^0.2.13", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.11.2", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 6f55cc6190..cad1df4ecc 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -18,7 +18,10 @@ import React, { ComponentType, ReactNode, ReactElement } from 'react'; import { MemoryRouter } from 'react-router'; import { Route } from 'react-router-dom'; import { lightTheme } from '@backstage/theme'; -import { createApp } from '@backstage/core-app-api'; +import { ThemeProvider } from '@material-ui/core/styles'; +import { CssBaseline } from '@material-ui/core'; +import MockIcon from '@material-ui/icons/AcUnit'; +import { createSpecializedApp } from '@backstage/core-app-api'; import { BootErrorPageProps, RouteRef, @@ -28,8 +31,34 @@ import { } from '@backstage/core-plugin-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from './testingLibrary'; +import { defaultApis } from './defaultApis'; import { mockApis } from './mockApis'; +const mockIcons = { + 'kind:api': MockIcon, + 'kind:component': MockIcon, + 'kind:domain': MockIcon, + 'kind:group': MockIcon, + 'kind:location': MockIcon, + 'kind:system': MockIcon, + 'kind:user': MockIcon, + + brokenImage: MockIcon, + catalog: MockIcon, + scaffolder: MockIcon, + techdocs: MockIcon, + search: MockIcon, + chat: MockIcon, + dashboard: MockIcon, + docs: MockIcon, + email: MockIcon, + github: MockIcon, + group: MockIcon, + help: MockIcon, + user: MockIcon, + warning: MockIcon, +}; + const ErrorBoundaryFallback = ({ error }: { error: Error }) => { throw new Error(`Reached ErrorBoundaryFallback Page with error, ${error}`); }; @@ -90,8 +119,9 @@ export function wrapInTestApp( const { routeEntries = ['/'] } = options; const boundRoutes = new Map(); - const app = createApp({ + const app = createSpecializedApp({ apis: mockApis, + defaultApis, // Bit of a hack to make sure that the default config loader isn't used // as that would force every single test to wait for config loading. configLoader: false as unknown as undefined, @@ -104,13 +134,18 @@ export function wrapInTestApp( ), }, + icons: mockIcons, plugins: [], themes: [ { id: 'light', - theme: lightTheme, title: 'Test App Theme', variant: 'light', + Provider: ({ children }) => ( + + {children} + + ), }, ], bindRoutes: ({ bind }) => { diff --git a/packages/core-app-api/src/app/defaultApis.ts b/packages/test-utils/src/testUtils/defaultApis.ts similarity index 96% rename from packages/core-app-api/src/app/defaultApis.ts rename to packages/test-utils/src/testUtils/defaultApis.ts index fb02274cf0..f06e1ba6b7 100644 --- a/packages/core-app-api/src/app/defaultApis.ts +++ b/packages/test-utils/src/testUtils/defaultApis.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,7 +34,7 @@ import { OneLoginAuth, UnhandledErrorForwarder, AtlassianAuth, -} from '../apis'; +} from '@backstage/core-app-api'; import { createApiFactory, @@ -59,8 +59,8 @@ import { atlassianAuthApiRef, } from '@backstage/core-plugin-api'; -import OAuth2Icon from '@material-ui/icons/AcUnit'; - +// TODO(Rugvip): This is just a copy of the createApp default APIs for now, but +// we should clean up this list a bit move more things over to mocks. export const defaultApis = [ createApiFactory({ api: discoveryApiRef, @@ -226,7 +226,7 @@ export const defaultApis = [ provider: { id: 'oidc', title: 'Your Identity Provider', - icon: OAuth2Icon, + icon: () => null, }, environment: configApi.getOptionalString('auth.environment'), }), diff --git a/plugins/catalog-backend/.snyk b/plugins/catalog-backend/.snyk new file mode 100644 index 0000000000..ad62599e66 --- /dev/null +++ b/plugins/catalog-backend/.snyk @@ -0,0 +1,47 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-TAR-1579155: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579152: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1579147: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536758: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536531: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z + SNYK-JS-TAR-1536528: + - 'sqlite3 > node-gyp > tar': + reason: >- + The only usage is via node-gyp; there is no unpacking of untrusted tar + files + expires: 2022-11-11T14:30:05.581Z + created: 2021-11-11T14:30:05.582Z +patch: {} diff --git a/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx index 584cffc50c..5ecc7f15d4 100644 --- a/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx +++ b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx @@ -28,6 +28,10 @@ import { StructuredMetadataTable, SupportButton, } from '@backstage/core-components'; +import { Link as RouterLink } from 'react-router-dom'; + +import { useRouteRef } from '@backstage/core-plugin-api'; +import { rootRouteRef } from '../../routes'; export const Project = () => { const [projectName, setProjectName] = useState(''); @@ -79,18 +83,20 @@ export const Project = () => {
@@ -127,12 +130,24 @@ const DetailsPage = () => { > {details?.name && ( )} {details?.name && ( )} @@ -151,16 +166,20 @@ const labels = ( ); -export const ProjectDetailsPage = () => ( - -
- {labels} -
- - - Support Button - - - -
-); +export const ProjectDetailsPage = () => { + const docsRootLink = useRouteRef(rootRouteRef)(); + + return ( + +
+ {labels} +
+ + + Support Button + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx index db3999272a..b37dc2fa50 100644 --- a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx +++ b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx @@ -15,19 +15,9 @@ */ // NEEDS WORK -import { - Button, - LinearProgress, - Paper, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - Typography, -} from '@material-ui/core'; +import { Button, LinearProgress, Tooltip, Typography } from '@material-ui/core'; import React from 'react'; + import { useAsync } from 'react-use'; import { gcpApiRef, Project } from '../../api'; @@ -38,12 +28,15 @@ import { HeaderLabel, Link, Page, + Table, SupportButton, WarningPanel, } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; +import { Link as RouterLink } from 'react-router-dom'; + const LongText = ({ text, max }: { text: string; max: number }) => { if (text.length < max) { return {text}; @@ -77,60 +70,59 @@ const PageContents = () => { ); } + function renderLink(id: string) { + return ( + + + + + + ); + } + return ( -
-
- - - Name - Project Number - Project ID - State - Creation Time - - - - {value?.map((project: Project) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
- +
+ renderLink(rowData.id), + }, + { + field: 'state', + title: 'State', + }, + { + field: 'creationTime', + title: 'Creation Time', + }, + ]} + data={ + value?.map((project: Project) => ({ + id: project.projectId, + name: project.name, + projectNumber: project?.projectNumber || 'Error', + projectID: project.projectId, + state: project?.lifecycleState || 'Error', + creationTime: project?.createTime || 'Error', + })) || [] + } + options={{ + pageSize: 5, + pageSizeOptions: [5, 10, 25, 50, 100], + }} + /> + ); }; @@ -141,7 +133,12 @@ export const ProjectListPage = () => ( - All your software catalog entities diff --git a/scripts/snyk-github-issue-sync.ts b/scripts/snyk-github-issue-sync.ts index d7f64cf1ff..f1015f8139 100644 --- a/scripts/snyk-github-issue-sync.ts +++ b/scripts/snyk-github-issue-sync.ts @@ -82,9 +82,9 @@ const fetchSnykGithubIssueMap = async (): Promise> => { const generateIssueBody = (vulnerability: Vulnerability) => ` ## Affecting Packages/Plugins -${Array.from(vulnerability.packages).map( - ({ name, target }) => `* [${name}](${target})\n`, -)} +${Array.from(vulnerability.packages) + .map(({ name, target }) => `* [${name}](${target})`) + .join('\n')} ${vulnerability.description} `; diff --git a/yarn.lock b/yarn.lock index 4c42941ad9..1b1a658a1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7288,9 +7288,9 @@ pretty-format "^26.0.0" "@types/jquery@^3.3.34": - version "3.5.5" - resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz#2c63f47c9c8d96693d272f5453602afd8338c903" - integrity sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w== + version "3.5.8" + resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.8.tgz#83bfbcdf4e625c5471590f92703c06aadb052a09" + integrity sha512-cXk6NwqjDYg+UI9p2l3x0YmPa4m7RrXqmbK4IpVVpRJiYXU/QTo+UZrn54qfE1+9Gao4qpYqUnxm5ZCy2FTXAw== dependencies: "@types/sizzle" "*" @@ -7560,9 +7560,9 @@ integrity sha512-4QQmOF5KlwfxJ5IGXFIudkeLCdMABz03RcUXu+LCb24zmln8QW6aDjuGl4d4XPVLf2j+FnjelHTP7dvceAFbhA== "@types/nunjucks@^3.1.4": - version "3.1.5" - resolved "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.1.5.tgz#e0782333b75b4ff9ac9213aa160ca8c5132eaf57" - integrity sha512-0zEdmQNNvQ+xyV9kqQvAV93UVroTwhE78toVUDT0GBnGcW2jQBZnB4al9qq2LqI5qHOqROy/DvvAY/UwrbvV1A== + version "3.2.0" + resolved "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.0.tgz#2303629c91cdd39de103af979046d4a8590351e4" + integrity sha512-1FM36Hm3EdidJmWlZZafkg/kZME0UZ/0vQ46JE8R7R0JqQafah0r+d4i6d/MJg5DnKxEeAOAeifEVkzo7fEvGg== "@types/oauth@*": version "0.9.1" @@ -20895,6 +20895,11 @@ node-abi@^2.21.0: dependencies: semver "^5.4.1" +node-abort-controller@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e" + integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw== + node-addon-api@^3.0.0: version "3.2.1" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" @@ -29435,6 +29440,11 @@ zip-stream@^4.1.0: compress-commons "^4.1.0" readable-stream "^3.6.0" +zod@^3.9.5: + version "3.11.6" + resolved "https://registry.npmjs.org/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" + integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"