diff --git a/.changeset/blue-eyes-run.md b/.changeset/blue-eyes-run.md new file mode 100644 index 0000000000..1b96e01f1c --- /dev/null +++ b/.changeset/blue-eyes-run.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-devtools': minor +'@backstage/plugin-devtools-backend': minor +'@backstage/plugin-devtools-common': minor +--- + +Introduced the DevTools plugin, checkout the plugin's [`README.md`](https://github.com/backstage/backstage/tree/master/plugins/devtools) for more details! diff --git a/packages/app/package.json b/packages/app/package.json index d4941ecd48..5987ec44a0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,7 @@ "@backstage/plugin-cloudbuild": "workspace:^", "@backstage/plugin-code-coverage": "workspace:^", "@backstage/plugin-cost-insights": "workspace:^", + "@backstage/plugin-devtools": "workspace:^", "@backstage/plugin-dynatrace": "workspace:^", "@backstage/plugin-entity-feedback": "workspace:^", "@backstage/plugin-explore": "workspace:^", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 19edd9640d..bc9eac8c64 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -109,6 +109,8 @@ import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; import { StackstormPage } from '@backstage/plugin-stackstorm'; import { PuppetDbPage } from '@backstage/plugin-puppetdb'; +import { DevToolsPage } from '@backstage/plugin-devtools'; +import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; const app = createApp({ apis, @@ -291,6 +293,9 @@ const routes = ( } /> } /> } /> + }> + {customDevToolsPage} + ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 904fa4a7d4..ffaf72296a 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -52,6 +52,7 @@ import { MyGroupsSidebarItem } from '@backstage/plugin-org'; import { SearchModal } from '../search/SearchModal'; import Score from '@material-ui/icons/Score'; import { useApp } from '@backstage/core-plugin-api'; +import BuildIcon from '@material-ui/icons/Build'; const useSidebarLogoStyles = makeStyles({ root: { @@ -176,6 +177,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( to="/settings" > + {children} diff --git a/packages/app/src/components/devtools/CustomDevToolsPage.tsx b/packages/app/src/components/devtools/CustomDevToolsPage.tsx new file mode 100644 index 0000000000..dfe70426aa --- /dev/null +++ b/packages/app/src/components/devtools/CustomDevToolsPage.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 { + ConfigContent, + ExternalDependenciesContent, + InfoContent, +} from '@backstage/plugin-devtools'; +import { DevToolsLayout } from '@backstage/plugin-devtools'; +import React from 'react'; + +const DevToolsPage = () => { + return ( + + + + + + + + + + + + ); +}; + +export const customDevToolsPage = ; diff --git a/packages/backend/package.json b/packages/backend/package.json index 900ee150fc..337d557788 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -42,6 +42,7 @@ "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-code-coverage-backend": "workspace:^", + "@backstage/plugin-devtools-backend": "workspace:^", "@backstage/plugin-entity-feedback-backend": "workspace:^", "@backstage/plugin-events-backend": "workspace:^", "@backstage/plugin-events-node": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 593d30bc3b..976ad9a577 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -64,6 +64,7 @@ import playlist from './plugins/playlist'; import adr from './plugins/adr'; import lighthouse from './plugins/lighthouse'; import linguist from './plugins/linguist'; +import devTools from './plugins/devtools'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -158,8 +159,8 @@ async function main() { const eventsEnv = useHotMemoize(module, () => createEnv('events')); const exploreEnv = useHotMemoize(module, () => createEnv('explore')); const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse')); - const linguistEnv = useHotMemoize(module, () => createEnv('linguist')); + const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -185,6 +186,7 @@ async function main() { apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv)); apiRouter.use('/adr', await adr(adrEnv)); apiRouter.use('/linguist', await linguist(linguistEnv)); + apiRouter.use('/devtools', await devTools(devToolsEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/devtools.ts b/packages/backend/src/plugins/devtools.ts new file mode 100644 index 0000000000..8e1767ddb1 --- /dev/null +++ b/packages/backend/src/plugins/devtools.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2022 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 { createRouter } from '@backstage/plugin-devtools-backend'; +import { Router } from 'express'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return createRouter({ + logger: env.logger, + config: env.config, + permissions: env.permissions, + }); +} diff --git a/plugins/devtools-backend/.eslintrc.js b/plugins/devtools-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/devtools-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/devtools-backend/README.md b/plugins/devtools-backend/README.md new file mode 100644 index 0000000000..e505a04963 --- /dev/null +++ b/plugins/devtools-backend/README.md @@ -0,0 +1,56 @@ +# DevTools Backend + +Welcome to the DevTools backend plugin! This plugin provides data for the [DevTools frontend](../devtools/) features. + +## Setup + +Here's how to get the DevTools Backend up and running: + +1. First we need to add the `@backstage/plugin-devtools-backend` package to your backend: + + ```sh + # From the Backstage root directory + cd packages/backend + yarn add @backstage/plugin-devtools-backend + ``` + +2. Then we will create a new file named `packages/backend/src/plugins/devtools.ts`, and add the + following to it: + + ```ts + import { createRouter } from '@backstage/plugin-devtools-backend'; + import { Router } from 'express'; + import type { PluginEnvironment } from '../types'; + + export default function createPlugin( + env: PluginEnvironment, + ): Promise { + return createRouter({ + logger: env.logger, + config: env.config, + permissions: env.permissions, + }); + } + ``` + +3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`: + + ```ts + import devTools from './plugins/devtools'; + // ... + async function main() { + // ... + // Add this line under the other lines that follow the useHotMemoize pattern + const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); + // ... + // Insert this line under the other lines that add their routers to apiRouter in the same way + apiRouter.use('/devtools', await devTools(devToolsEnv)); + ``` + +4. Now run `yarn start-backend` from the repo root +5. Finally open `http://localhost:7007/api/devtools/health` in a browser and it should return `{"status":"ok"}` + +## Links + +- [Frontend part of the plugin](../devtools/README.md) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/devtools-backend/api-report.md b/plugins/devtools-backend/api-report.md new file mode 100644 index 0000000000..adf2189512 --- /dev/null +++ b/plugins/devtools-backend/api-report.md @@ -0,0 +1,41 @@ +## API Report File for "@backstage/plugin-devtools-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import { ConfigInfo } from '@backstage/plugin-devtools-common'; +import { DevToolsInfo } from '@backstage/plugin-devtools-common'; +import express from 'express'; +import { ExternalDependency } from '@backstage/plugin-devtools-common'; +import { Logger } from 'winston'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export class DevToolsBackendApi { + constructor(logger: Logger, config: Config); + // (undocumented) + listConfig(): Promise; + // (undocumented) + listExternalDependencyDetails(): Promise; + // (undocumented) + listInfo(): Promise; +} + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + devToolsBackendApi?: DevToolsBackendApi; + // (undocumented) + logger: Logger; + // (undocumented) + permissions: PermissionEvaluator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/devtools-backend/config.d.ts b/plugins/devtools-backend/config.d.ts new file mode 100644 index 0000000000..15e70afcf8 --- /dev/null +++ b/plugins/devtools-backend/config.d.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 interface Config { + /** + * DevTools configuration. + */ + devTools?: { + /** + * External dependency configuration. + */ + externalDependencies?: { + /** + * The list of endpoints to check. + */ + endpoints?: Array<{ + /** + * The name of the endpoint. + */ + name: string; + /** + * Type of check to perform; currently fetch or ping + */ + type: string; + /** + * The target of the endpoint; currently either a URL for fetch or server name for ping. + */ + target: string; + }>; + }; + }; +} diff --git a/plugins/devtools-backend/package.json b/plugins/devtools-backend/package.json new file mode 100644 index 0000000000..94dd803af4 --- /dev/null +++ b/plugins/devtools-backend/package.json @@ -0,0 +1,63 @@ +{ + "name": "@backstage/plugin-devtools-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/cli-common": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/config-loader": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-devtools-common": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", + "@backstage/types": "workspace:^", + "@manypkg/get-packages": "^1.1.3", + "@types/express": "*", + "@yarnpkg/lockfile": "^1.1.0", + "@yarnpkg/parsers": "^3.0.0-rc.4", + "express": "^4.18.1", + "express-promise-router": "^4.1.0", + "fs-extra": "^10.0.0", + "lodash": "^4.17.21", + "node-fetch": "^2.6.7", + "ping": "^0.4.1", + "semver": "^7.3.2", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/minimist": "^1.2.0", + "@types/ping": "^0.4.1", + "@types/supertest": "^2.0.8", + "@types/yarnpkg__lockfile": "^1.1.4", + "msw": "^0.47.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/devtools-backend/src/api/DevToolsBackendApi.ts b/plugins/devtools-backend/src/api/DevToolsBackendApi.ts new file mode 100644 index 0000000000..88bd51fa7e --- /dev/null +++ b/plugins/devtools-backend/src/api/DevToolsBackendApi.ts @@ -0,0 +1,252 @@ +/* + * Copyright 2022 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 { Config, ConfigReader } from '@backstage/config'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { + PackageDependency, + DevToolsInfo, + ExternalDependency, + Endpoint, + ExternalDependencyStatus, + ConfigInfo, +} from '@backstage/plugin-devtools-common'; + +import { JsonObject } from '@backstage/types'; +import { Logger } from 'winston'; +import fetch from 'node-fetch'; +import { findPaths } from '@backstage/cli-common'; +import { getPackages } from '@manypkg/get-packages'; +import ping from 'ping'; +import os from 'os'; +import fs from 'fs-extra'; +import { Lockfile } from '../util/Lockfile'; +import { memoize } from 'lodash'; +import { assertError } from '@backstage/errors'; + +/** @public */ +export class DevToolsBackendApi { + public constructor( + private readonly logger: Logger, + private readonly config: Config, + ) {} + + public async listExternalDependencyDetails(): Promise { + const result: ExternalDependency[] = []; + + const endpoints = this.config.getOptional( + 'devTools.externalDependencies.endpoints', + ); + if (!endpoints) { + // No external dependency endpoints configured + return result; + } + for (const endpoint of endpoints) { + this.logger?.info( + `Checking external dependency "${endpoint.name}" at "${endpoint.target}"`, + ); + + switch (endpoint.type) { + case 'ping': { + const pingResult = await this.pingExternalDependency(endpoint); + result.push(pingResult); + break; + } + case 'fetch': { + const fetchResult = await this.fetchExternalDependency(endpoint); + result.push(fetchResult); + break; + } + default: + return result; + } + } + + return result; + } + + private async fetchExternalDependency( + endpoint: Endpoint, + ): Promise { + let status; + let error; + + await fetch(endpoint.target) + .then(res => { + status = + res.status === 200 + ? ExternalDependencyStatus.healthy + : ExternalDependencyStatus.unhealthy; + this.logger.info( + `Fetch for ${endpoint.name} resulted in status code "${res.status}"`, + ); + }) + .catch((err: Error) => { + this.logger.error(`Fetch failed for ${endpoint.name} - ${err.message}`); + error = err.message; + }); + + const result: ExternalDependency = { + name: endpoint.name, + type: endpoint.type, + target: endpoint.target, + status: status ?? ExternalDependencyStatus.unhealthy, + error: error ?? undefined, + }; + + return result; + } + + private async pingExternalDependency( + endpoint: Endpoint, + ): Promise { + const pingResult = await ping.promise.probe(endpoint.target); + + let error; + if ( + pingResult.packetLoss === '100.000' || + pingResult.packetLoss === 'unknown' + ) { + this.logger.error( + `Ping failed for ${endpoint.name} - ${pingResult.output}`, + ); + error = + pingResult.output === '' + ? `${endpoint.target} - Unknown` + : pingResult.output; + } + + this.logger.info(`Ping results for ${endpoint.name}: ${pingResult.output}`); + + const result: ExternalDependency = { + name: endpoint.name, + type: endpoint.type, + target: endpoint.target, + status: pingResult.alive + ? ExternalDependencyStatus.healthy + : ExternalDependencyStatus.unhealthy, + error: error ?? undefined, + }; + + return result; + } + + public async listConfig(): Promise { + /* eslint-disable-next-line no-restricted-syntax */ + const paths = findPaths(__dirname); + + const { packages } = await getPackages(paths.targetDir); + const schemaFunc = async () => { + return await loadConfigSchema({ + dependencies: packages.map(p => p.packageJson.name), + }); + }; + + const schemaMemo = memoize(schemaFunc); + const schema = await schemaMemo(); + + const configInfo: ConfigInfo = { + config: undefined, + error: undefined, + }; + try { + const config = { + data: this.config.get() as JsonObject, + context: 'inline', + }; + const sanitizedConfigs = schema.process([config], { + ignoreSchemaErrors: false, + valueTransform: (value, context) => + context.visibility === 'secret' ? '' : value, + }); + + const data = ConfigReader.fromConfigs(sanitizedConfigs).get(); + configInfo.config = data; + } catch (error) { + assertError(error); + // The config is not valid for some reason but we want to be able to see it still + const config = { + data: this.config.get() as JsonObject, + context: 'inline', + }; + const sanitizedConfigs = schema.process([config], { + ignoreSchemaErrors: true, + valueTransform: (value, context) => + context.visibility === 'secret' ? '' : value, + }); + + const data = ConfigReader.fromConfigs(sanitizedConfigs).get(); + configInfo.config = data; + configInfo.error = { + name: error.name, + message: error.message, + messages: error.messages as string[] | undefined, + stack: error.stack, + }; + } + + return configInfo; + } + + public async listInfo(): Promise { + const operatingSystem = `${os.type} ${os.release} - ${os.platform}/${os.arch}`; + const nodeJsVersion = process.version; + + /* eslint-disable-next-line no-restricted-syntax */ + const paths = findPaths(__dirname); + const backstageFile = paths.resolveTargetRoot('backstage.json'); + let backstageJson = undefined; + if (fs.existsSync(backstageFile)) { + const buffer = await fs.readFile(backstageFile); + backstageJson = JSON.parse(buffer.toString()); + } + + const lockfilePath = paths.resolveTargetRoot('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); + + const deps = [...lockfile.keys()].filter(n => n.startsWith('@backstage/')); + + const infoDependencies: PackageDependency[] = []; + for (const dep of deps) { + const versions = new Set(lockfile.get(dep)!.map(i => i.version)); + const infoDependency: PackageDependency = { + name: dep, + versions: [...versions].join(', '), + }; + infoDependencies.push(infoDependency); + } + + const info: DevToolsInfo = { + operatingSystem: operatingSystem ?? 'N/A', + nodeJsVersion: nodeJsVersion ?? 'N/A', + backstageVersion: + backstageJson && backstageJson.version ? backstageJson.version : 'N/A', + dependencies: infoDependencies, + }; + + return info; + } +} + +export function isValidUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/plugins/devtools-backend/src/api/index.ts b/plugins/devtools-backend/src/api/index.ts new file mode 100644 index 0000000000..ed3390b113 --- /dev/null +++ b/plugins/devtools-backend/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { DevToolsBackendApi } from './DevToolsBackendApi'; diff --git a/plugins/devtools-backend/src/index.ts b/plugins/devtools-backend/src/index.ts new file mode 100644 index 0000000000..134b797761 --- /dev/null +++ b/plugins/devtools-backend/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 { DevToolsBackendApi } from './api'; +export * from './service/router'; diff --git a/plugins/devtools-backend/src/run.ts b/plugins/devtools-backend/src/run.ts new file mode 100644 index 0000000000..d945aa13f0 --- /dev/null +++ b/plugins/devtools-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2022 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/devtools-backend/src/service/router.test.ts b/plugins/devtools-backend/src/service/router.test.ts new file mode 100644 index 0000000000..f3c78242cf --- /dev/null +++ b/plugins/devtools-backend/src/service/router.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2022 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 { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { createRouter } from './router'; + +const mockedAuthorize: jest.MockedFunction = + jest.fn(); +const mockedPermissionQuery: jest.MockedFunction< + PermissionEvaluator['authorizeConditional'] +> = jest.fn(); + +const permissionEvaluator: PermissionEvaluator = { + authorize: mockedAuthorize, + authorizeConditional: mockedPermissionQuery, +}; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + config: new ConfigReader({ + healthCheck: { + endpoint: [ + { + name: '', + type: '', + target: '', + }, + ], + }, + }), + permissions: permissionEvaluator, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/devtools-backend/src/service/router.ts b/plugins/devtools-backend/src/service/router.ts new file mode 100644 index 0000000000..a346ad3183 --- /dev/null +++ b/plugins/devtools-backend/src/service/router.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2022 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 { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; +import { + devToolsConfigReadPermission, + devToolsExternalDependenciesReadPermission, + devToolsInfoReadPermission, +} from '@backstage/plugin-devtools-common'; + +import { Config } from '@backstage/config'; +import { DevToolsBackendApi } from '../api'; +import { Logger } from 'winston'; +import { NotAllowedError } from '@backstage/errors'; +import Router from 'express-promise-router'; +import { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; + +/** @public */ +export interface RouterOptions { + devToolsBackendApi?: DevToolsBackendApi; + logger: Logger; + config: Config; + permissions: PermissionEvaluator; +} + +/** @public */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, config, permissions } = options; + + const devToolsBackendApi = + options.devToolsBackendApi || new DevToolsBackendApi(logger, config); + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_req, res) => { + res.status(200).json({ status: 'ok' }); + }); + + router.get('/info', async (req, response) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + const decision = ( + await permissions.authorize( + [{ permission: devToolsInfoReadPermission }], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + const info = await devToolsBackendApi.listInfo(); + + response.status(200).json(info); + }); + + router.get('/config', async (req, response) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + const decision = ( + await permissions.authorize( + [{ permission: devToolsConfigReadPermission }], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + const configList = await devToolsBackendApi.listConfig(); + + response.status(200).json(configList); + }); + + router.get('/external-dependencies', async (req, response) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + const decision = ( + await permissions.authorize( + [{ permission: devToolsExternalDependenciesReadPermission }], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + const health = await devToolsBackendApi.listExternalDependencyDetails(); + + response.status(200).json(health); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/devtools-backend/src/service/standaloneServer.ts b/plugins/devtools-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..d2fdce169d --- /dev/null +++ b/plugins/devtools-backend/src/service/standaloneServer.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2022 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 { + ServerTokenManager, + SingleHostDiscovery, + createServiceBuilder, + loadBackendConfig, +} from '@backstage/backend-common'; + +import { Logger } from 'winston'; +import { Server } from 'http'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'devtools-backend-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + config, + permissions, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/devtools-backend', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/devtools-backend/src/setupTests.ts b/plugins/devtools-backend/src/setupTests.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/devtools-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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/plugins/devtools-backend/src/util/Lockfile.ts b/plugins/devtools-backend/src/util/Lockfile.ts new file mode 100644 index 0000000000..52dbb847bc --- /dev/null +++ b/plugins/devtools-backend/src/util/Lockfile.ts @@ -0,0 +1,317 @@ +/* + * Copyright 2022 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 fs from 'fs-extra'; +import semver from 'semver'; +import { parseSyml, stringifySyml } from '@yarnpkg/parsers'; +import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile'; + +const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; + +type LockfileData = { + [entry: string]: { + version: string; + resolved?: string; + integrity?: string; + dependencies?: { [name: string]: string }; + }; +}; + +type LockfileQueryEntry = { + range: string; + version: string; +}; + +/** Entries that have an invalid version range, for example an npm tag */ +type AnalyzeResultInvalidRange = { + name: string; + range: string; +}; + +/** Entries that can be deduplicated by bumping to an existing higher version */ +type AnalyzeResultNewVersion = { + name: string; + range: string; + oldVersion: string; + newVersion: string; +}; + +/** Entries that would need a dependency update in package.json to be deduplicated */ +type AnalyzeResultNewRange = { + name: string; + oldRange: string; + newRange: string; + oldVersion: string; + newVersion: string; +}; + +type AnalyzeResult = { + invalidRanges: AnalyzeResultInvalidRange[]; + newVersions: AnalyzeResultNewVersion[]; + newRanges: AnalyzeResultNewRange[]; +}; + +function parseLockfile(lockfileContents: string) { + try { + return { + object: parseSyml(lockfileContents), + type: 'success', + }; + } catch (err) { + return { + object: null, + type: err, + }; + } +} + +// the new yarn header is handled out of band of the parsing +// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746 +const NEW_HEADER = `${[ + `# This file is generated by running "yarn install" inside your project.\n`, + `# Manual changes might be lost - proceed with caution!\n`, +].join(``)}\n`; + +function stringifyLockfile(data: LockfileData, legacy: boolean) { + return legacy + ? legacyStringifyLockfile(data) + : NEW_HEADER + stringifySyml(data); +} +// taken from yarn parser package +// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136 +const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i; + +// these are special top level yarn keys. +// https://github.com/yarnpkg/berry/blob/9bd61fbffb83d0b8166a9cc26bec3a58743aa453/packages/yarnpkg-parsers/sources/syml.ts#L9 +const SPECIAL_OBJECT_KEYS = [ + `__metadata`, + `version`, + `resolution`, + `dependencies`, + `peerDependencies`, + `dependenciesMeta`, + `peerDependenciesMeta`, + `binaries`, +]; + +export class Lockfile { + static async load(path: string) { + const lockfileContents = await fs.readFile(path, 'utf8'); + const legacy = LEGACY_REGEX.test(lockfileContents); + const lockfile = parseLockfile(lockfileContents); + if (lockfile.type !== 'success') { + throw new Error(`Failed yarn.lock parse with ${lockfile.type}`); + } + + const data = lockfile.object as LockfileData; + const packages = new Map(); + + for (const [key, value] of Object.entries(data)) { + if (SPECIAL_OBJECT_KEYS.includes(key)) continue; + + const [, name, range] = ENTRY_PATTERN.exec(key) ?? []; + if (!name) { + throw new Error(`Failed to parse yarn.lock entry '${key}'`); + } + + let queries = packages.get(name); + if (!queries) { + queries = []; + packages.set(name, queries); + } + queries.push({ range, version: value.version }); + } + + return new Lockfile(path, packages, data, legacy); + } + + private constructor( + private readonly path: string, + private readonly packages: Map, + private readonly data: LockfileData, + private readonly legacy: boolean = false, + ) {} + + /** Get the entries for a single package in the lockfile */ + get(name: string): LockfileQueryEntry[] | undefined { + return this.packages.get(name); + } + + /** Returns the name of all packages available in the lockfile */ + keys(): IterableIterator { + return this.packages.keys(); + } + + /** Analyzes the lockfile to identify possible actions and warnings for the entries */ + analyze(options?: { filter?: (name: string) => boolean }): AnalyzeResult { + const { filter } = options ?? {}; + const result: AnalyzeResult = { + invalidRanges: [], + newVersions: [], + newRanges: [], + }; + + for (const [name, allEntries] of this.packages) { + if (filter && !filter(name)) { + continue; + } + + // Get rid of and signal any invalid ranges upfront + const invalid = allEntries.filter(e => !semver.validRange(e.range)); + result.invalidRanges.push( + ...invalid.map(({ range }) => ({ name, range })), + ); + + // Grab all valid entries, if there aren't at least 2 different valid ones we're done + const entries = allEntries.filter(e => semver.validRange(e.range)); + if (entries.length < 2) { + continue; + } + + // Find all versions currently in use + const versions = Array.from(new Set(entries.map(e => e.version))).sort( + (v1, v2) => semver.rcompare(v1, v2), + ); + + // If we're not using at least 2 different versions we're done + if (versions.length < 2) { + continue; + } + + const acceptedVersions = new Set(); + for (const { version, range } of entries) { + // Finds the highest matching version from the the known versions + // TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one + const acceptedVersion = versions.find(v => semver.satisfies(v, range)); + if (!acceptedVersion) { + throw new Error( + `No existing version was accepted for range ${range}, searching through ${versions}, for package ${name}`, + ); + } + + if (acceptedVersion !== version) { + result.newVersions.push({ + name, + range, + newVersion: acceptedVersion, + oldVersion: version, + }); + } + + acceptedVersions.add(acceptedVersion); + } + + // If all ranges were able to accept the same version, we're done + if (acceptedVersions.size === 1) { + continue; + } + + // Find the max version that we may want bump older packages to + const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0]; + // Find all existing ranges that satisfy the new max version, and pick the one that + // results in the highest minimum allowed version, usually being the more specific one + const maxEntry = entries + .filter(e => semver.satisfies(maxVersion, e.range)) + .map(e => ({ e, min: semver.minVersion(e.range) })) + .filter(p => p.min) + .sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e; + if (!maxEntry) { + throw new Error( + `No entry found that satisfies max version '${maxVersion}'`, + ); + } + + // Find all entries that don't satisfy the max version + for (const { version, range } of entries) { + if (semver.satisfies(maxVersion, range)) { + continue; + } + + result.newRanges.push({ + name, + oldRange: range, + newRange: maxEntry.range, + oldVersion: version, + newVersion: maxVersion, + }); + } + } + + return result; + } + + remove(name: string, range: string): boolean { + const query = `${name}@${range}`; + const existed = Boolean(this.data[query]); + delete this.data[query]; + + const newEntries = this.packages.get(name)?.filter(e => e.range !== range); + if (newEntries) { + this.packages.set(name, newEntries); + } + + return existed; + } + + /** Modifies the lockfile by bumping packages to the suggested versions */ + replaceVersions(results: AnalyzeResultNewVersion[]) { + for (const { name, range, oldVersion, newVersion } of results) { + const query = `${name}@${range}`; + + // Update the backing data + const entryData = this.data[query]; + if (!entryData) { + throw new Error(`No entry data for ${query}`); + } + if (entryData.version !== oldVersion) { + throw new Error( + `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + ); + } + + // Modifying the data in the entry is not enough, we need to reference an existing version object + const matchingEntry = Object.entries(this.data).find( + ([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion, + ); + if (!matchingEntry) { + throw new Error( + `No matching entry found for ${name} at version ${newVersion}`, + ); + } + this.data[query] = matchingEntry[1]; + + // Update our internal data structure + const entry = this.packages.get(name)?.find(e => e.range === range); + if (!entry) { + throw new Error(`No entry data for ${query}`); + } + if (entry.version !== oldVersion) { + throw new Error( + `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + ); + } + entry.version = newVersion; + } + } + + async save() { + await fs.writeFile(this.path, this.toString(), 'utf8'); + } + + toString() { + return stringifyLockfile(this.data, this.legacy); + } +} diff --git a/plugins/devtools-common/.eslintrc.js b/plugins/devtools-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/devtools-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/devtools-common/README.md b/plugins/devtools-common/README.md new file mode 100644 index 0000000000..5be5e58773 --- /dev/null +++ b/plugins/devtools-common/README.md @@ -0,0 +1,3 @@ +# DevTools Common + +Common types and permissions for the DevTools plugin. diff --git a/plugins/devtools-common/api-report.md b/plugins/devtools-common/api-report.md new file mode 100644 index 0000000000..dae445abde --- /dev/null +++ b/plugins/devtools-common/api-report.md @@ -0,0 +1,74 @@ +## API Report File for "@backstage/plugin-devtools-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; +import { JsonValue } from '@backstage/types'; + +// @public (undocumented) +export type ConfigError = { + name: string; + message: string; + messages?: string[]; + stack?: string; +}; + +// @public (undocumented) +export type ConfigInfo = { + config?: JsonValue; + error?: ConfigError; +}; + +// @public (undocumented) +export const devToolsAdministerPermission: BasicPermission; + +// @public (undocumented) +export const devToolsConfigReadPermission: BasicPermission; + +// @public (undocumented) +export const devToolsExternalDependenciesReadPermission: BasicPermission; + +// @public (undocumented) +export type DevToolsInfo = { + operatingSystem: string; + nodeJsVersion: string; + backstageVersion: string; + dependencies: PackageDependency[]; +}; + +// @public (undocumented) +export const devToolsInfoReadPermission: BasicPermission; + +// @public (undocumented) +export type Endpoint = { + name: string; + type: string; + target: string; +}; + +// @public (undocumented) +export type ExternalDependency = { + name: string; + type: string; + target: string; + status: string; + error?: string; +}; + +// @public (undocumented) +export enum ExternalDependencyStatus { + // (undocumented) + healthy = 'Healthy', + // (undocumented) + unhealthy = 'Unhealthy', +} + +// @public (undocumented) +export type PackageDependency = { + name: string; + versions: string; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/devtools-common/package.json b/plugins/devtools-common/package.json new file mode 100644 index 0000000000..e4ceb3ee58 --- /dev/null +++ b/plugins/devtools-common/package.json @@ -0,0 +1,35 @@ +{ + "name": "@backstage/plugin-devtools-common", + "description": "Common functionalities for the devtools plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/types": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/devtools-common/src/index.ts b/plugins/devtools-common/src/index.ts new file mode 100644 index 0000000000..ca1cec9ac1 --- /dev/null +++ b/plugins/devtools-common/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 * from './types'; +export * from './permissions'; diff --git a/plugins/devtools-common/src/permissions.ts b/plugins/devtools-common/src/permissions.ts new file mode 100644 index 0000000000..ae62cfdc19 --- /dev/null +++ b/plugins/devtools-common/src/permissions.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2022 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 { createPermission } from '@backstage/plugin-permission-common'; + +/** + * @public + */ +export const devToolsAdministerPermission = createPermission({ + name: 'devtools.administer', + attributes: { action: 'read' }, +}); + +/** + * @public + */ +export const devToolsInfoReadPermission = createPermission({ + name: 'devtools.info', + attributes: { action: 'read' }, +}); + +/** + * @public + */ +export const devToolsConfigReadPermission = createPermission({ + name: 'devtools.config', + attributes: { action: 'read' }, +}); + +/** + * @public + */ +export const devToolsExternalDependenciesReadPermission = createPermission({ + name: 'devtools.external-dependencies', + attributes: { action: 'read' }, +}); diff --git a/plugins/devtools-common/src/setupTests.ts b/plugins/devtools-common/src/setupTests.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/devtools-common/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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/plugins/devtools-common/src/types.ts b/plugins/devtools-common/src/types.ts new file mode 100644 index 0000000000..d08232b155 --- /dev/null +++ b/plugins/devtools-common/src/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2022 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 { JsonValue } from '@backstage/types'; + +/** @public */ +export type Endpoint = { + name: string; + type: string; + target: string; +}; + +/** @public */ +export type ExternalDependency = { + name: string; + type: string; + target: string; + status: string; + error?: string; +}; + +/** @public */ +export type DevToolsInfo = { + operatingSystem: string; + nodeJsVersion: string; + backstageVersion: string; + dependencies: PackageDependency[]; +}; + +/** @public */ +export type PackageDependency = { + name: string; + versions: string; +}; + +/** @public */ +export enum ExternalDependencyStatus { + healthy = 'Healthy', + unhealthy = 'Unhealthy', +} + +/** @public */ +export type ConfigInfo = { + config?: JsonValue; + error?: ConfigError; +}; + +/** @public */ +export type ConfigError = { + name: string; + message: string; + messages?: string[]; + stack?: string; +}; diff --git a/plugins/devtools/.eslintrc.js b/plugins/devtools/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/devtools/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/devtools/README.md b/plugins/devtools/README.md new file mode 100644 index 0000000000..d20142a746 --- /dev/null +++ b/plugins/devtools/README.md @@ -0,0 +1,375 @@ +# DevTools + +Welcome to the DevTools plugin! + +## Features + +The DevTools plugin comes with two tabs out of the box. + +### Info + +Lists helpful information about your current running Backstage instance such as: OS, NodeJS version, Backstage version, and package versions. + +![Example of Info tab](./docs/devtools-info-tab.png) + +### Config + +Lists the configuration being used by your current running Backstage instance. + +![Example of Config tab](./docs/devtools-config-tab.png) + +## Optional Features + +The DevTools plugin can be setup with other tabs with additional helpful features. + +### External Dependencies + +Lists the status of configured External Dependencies based on your current running Backstage instance's ability to reach them + +![Example of external dependencies tab](./docs/devtools-external-dependencies.png) + +## Setup + +The following sections will help you get the DevTools plugin setup and running. + +### Backend + +You need to setup the [DevTools backend plugin](../devtools-backend/README.md) before you move forward with any of the following steps if you haven't already. + +### Frontend + +To setup the DevTools frontend you'll need to do the following steps: + +1. First we need to add the `@backstage/plugin-devtools` package to your frontend app: + + ```sh + # From your Backstage root directory + yarn add --cwd packages/app @backstage/plugin-devtools + ``` + +2. Now open the `packages/app/src/App.tsx` file +3. Then after all the import statements add the following line: + + ```ts + import { DevToolsPage } from '@backstage/plugin-devtools'; + ``` + +4. In this same file just before the closing ``, this will be near the bottom of the file, add this line: + + ```ts + } /> + ``` + +5. Next open the `packages/app/src/components/Root/Root.tsx` file +6. We want to add this icon import after all the existing import statements: + + ```ts + import BuildIcon from '@material-ui/icons/Build'; + ``` + +7. Then add this line just after the `` line: + + ```ts + + ``` + +8. Now run `yarn dev` from the root of your project and you should see the DevTools option show up just below Settings in your sidebar and clicking on it will get you to the [Info tab](#info) + +## Customizing + +The DevTools plugin has been designed so that you can customize the tabs to suite your needs. You may only want some or none of the out of the box tabs or you may want to add your own. The following sections explains how to do that (assuming you've already done the [setup steps](#setup)). As part of this example we'll also be showing how you can add the optional [External Dependencies](#external-dependencies) tab. + +1. In the `packages/app/src/components` folder create a new sub-folder called `devtools` +2. Then in this new `devtools` folder add a file called `CustomDevToolsPage.tsx` +3. In the `CustomDevToolsPage.tsx` file add the following content: + + ```tsx + import { + ConfigContent, + ExternalDependenciesContent, + InfoContent, + } from '@backstage/plugin-devtools'; + import { DevToolsLayout } from '@backstage/plugin-devtools'; + import React from 'react'; + + export const DevToolsPage = () => { + return ( + + + + + + + + + + + + ); + }; + + export const customDevToolsPage = ; + ``` + +4. Now open the `packages/app/src/App.tsx` file and add the following import after all the existing import statements: + + ```ts + import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; + ``` + +5. Then we need to adjust our route as follows + + ```diff + - } /> + + } > + + {customDevToolsPage} + + + ``` + +6. Now run `yarn dev` from the root of your project. When you go to the DevTools you'll now see you have a third tab for [External Dependencies](#external-dependencies) + +With this setup you can add or remove the tabs as you'd like or add your own simply by editing your `CustomDevToolsPage.tsx` file + +## Permissions + +The DevTools plugin supports the [permissions framework](https://backstage.io/docs/permissions/overview), the following sections outline how you can use them with the assumption that you have the permissions framework setup and working. + +**Note:** These sections are intended as guidance and are completely optional. The DevTools plugin will work with the permission framework off or on without any specific policy setup. + +### Secure Sidebar Option + +To use the permission framework to secure the DevTools sidebar option you'll want to do the following: + +1. First we need to add the `@backstage/plugin-devtools-common` package to your frontend app: + + ```sh + # From your Backstage root directory + yarn add --cwd packages/app @backstage/plugin-devtools + ``` + +2. Then open the `packages/app/src/components/Root/Root.tsx` file +3. The add these imports after all the existing import statements: + + ```ts + import { devToolsAdministerPermission } from '@backstage/plugin-devtools-common'; + import { RequirePermission } from '@backstage/plugin-permission-react'; + ``` + +4. Then make the following change: + + ```diff + - + + }> + + + + + ``` + +### Secure the DevTools Route + +To use the permission framework to secure the DevTools route you'll want to do the following: + +1. First we need to add the `@backstage/plugin-devtools-common` package to your frontend app (skip this step if you've already done this): + + ```sh + # From your Backstage root directory + yarn add --cwd packages/app @backstage/plugin-devtools-common + ``` + +2. Then open the `packages/app/src/App.tsx` file +3. The add this import after all the existing import statements: + + ```ts + import { devToolsAdministerPermission } from '@backstage/plugin-devtools-common'; + ``` + +4. Then make the following change: + + ```diff + - } /> + + + + + + + + } + + /> + ``` + +Note: if you are using a `customDevToolsPage` as per the [Customizing](#customizing) documentation the changes for Step 4 will be: + +```diff +- } /> ++ ++ ++ ++ } ++ > ++ {customDevToolsPage} ++ +``` + +### Permission Policy + +Here is an example permission policy that you might use to secure the DevTools plugin: + +```ts +// packages/backend/src/plugins/permission.ts + +class TestPermissionPolicy implements PermissionPolicy { + async handle(request: PolicyQuery): Promise { + if (isPermission(request.permission, devToolsAdministerPermission)) { + if ( + user?.identity.ownershipEntityRefs.includes( + 'group:default/backstage-admins', + ) + ) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + } + + if (isPermission(request.permission, devToolsInfoReadPermission)) { + if ( + user?.identity.ownershipEntityRefs.includes( + 'group:default/backstage-admins', + ) + ) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + } + + if (isPermission(request.permission, devToolsConfigReadPermission)) { + if ( + user?.identity.ownershipEntityRefs.includes( + 'group:default/backstage-admins', + ) + ) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + } + + if ( + isPermission( + request.permission, + devToolsExternalDependenciesReadPermission, + ) + ) { + if ( + user?.identity.ownershipEntityRefs.includes( + 'group:default/backstage-admins', + ) + ) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + } + + return { result: AuthorizeResult.ALLOW }; + } +} +``` + +To use this policy you'll need to make sure to add the `@backstage/plugin-devtools-common` package to your backend you can do that by running this command: + +```sh +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-devtools-common +``` + +You'll also need to add these imports: + +```ts +import { + devToolsAdministerPermission, + devToolsConfigReadPermission, + devToolsExternalDependenciesReadPermission, + devToolsInfoReadPermission, +} from '@backstage/plugin-devtools-common'; +``` + +**Note:** The group "group:default/backstage-admins" is simply an example and does not exist. You can point this to any group you have in your catalog instead. + +### Customizing with Permissions + +If you followed the [Customizing](#customizing) documentation and want to use permission there this is what your `CustomDevToolsPage.tsx` would look like: + +```tsx +import { + ConfigContent, + ExternalDependenciesContent, + InfoContent, +} from '@backstage/plugin-devtools'; +import { DevToolsLayout } from '@backstage/plugin-devtools'; +import { + devToolsConfigReadPermission, + devToolsExternalDependenciesReadPermission, + devToolsInfoReadPermission, +} from '@backstage/plugin-devtools-common'; +import { RequirePermission } from '@backstage/plugin-permission-react'; +import React from 'react'; + +const DevToolsPage = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const customDevToolsPage = ; +``` + +## Configuration + +The following sections outline the configuration for the DevTools plugin + +### External Dependencies Configuration + +If you decide to use the External Dependencies tab then you'll need to setup the configuration for it in your `app-config.yaml`, if there is no config setup then the tab will be empty. Here's an example: + +```yaml +devTools: + externalDependencies: + endpoints: + - name: 'Google' + type: 'fetch' + target: 'https://google.ca' + - name: 'Google Public DNS' + type: 'ping' + target: '8.8.8.8' +``` + +Configuration details: + +- `endpoints` is an array +- `name` is the friendly name for your endpoint +- `type` can be either `ping` or `fetch` and will perform the respective action on the `target` +- `target` is either a URL or server that you want to trigger a `type` action on diff --git a/plugins/devtools/api-report.md b/plugins/devtools/api-report.md new file mode 100644 index 0000000000..3933d50e76 --- /dev/null +++ b/plugins/devtools/api-report.md @@ -0,0 +1,59 @@ +## API Report File for "@backstage/plugin-devtools" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { default as default_2 } from 'react'; +import { RouteRef } from '@backstage/core-plugin-api'; +import { TabProps } from '@material-ui/core'; + +// @public (undocumented) +export const ConfigContent: () => JSX.Element; + +// @public +export const DevToolsLayout: { + ({ children }: DevToolsLayoutProps): JSX.Element; + Route: (props: SubRoute) => null; +}; + +// @public (undocumented) +export type DevToolsLayoutProps = { + children?: default_2.ReactNode; +}; + +// @public (undocumented) +export const DevToolsPage: () => JSX.Element; + +// @public (undocumented) +export const devToolsPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {}, + {} +>; + +// @public (undocumented) +export const ExternalDependenciesContent: () => JSX.Element; + +// @public (undocumented) +export const InfoContent: () => JSX.Element; + +// @public (undocumented) +export type SubRoute = { + path: string; + title: string; + children: JSX.Element; + tabProps?: TabProps< + default_2.ElementType, + { + component?: default_2.ElementType; + } + >; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/devtools/dev/index.tsx b/plugins/devtools/dev/index.tsx new file mode 100644 index 0000000000..00c966a801 --- /dev/null +++ b/plugins/devtools/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2022 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 { createDevApp } from '@backstage/dev-utils'; +import { devToolsPlugin, DevToolsPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(devToolsPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/devtools', + }) + .render(); diff --git a/plugins/devtools/docs/devtools-config-tab.png b/plugins/devtools/docs/devtools-config-tab.png new file mode 100644 index 0000000000..b5c22f691b Binary files /dev/null and b/plugins/devtools/docs/devtools-config-tab.png differ diff --git a/plugins/devtools/docs/devtools-external-dependencies.png b/plugins/devtools/docs/devtools-external-dependencies.png new file mode 100644 index 0000000000..9356d83ef5 Binary files /dev/null and b/plugins/devtools/docs/devtools-external-dependencies.png differ diff --git a/plugins/devtools/docs/devtools-info-tab.png b/plugins/devtools/docs/devtools-info-tab.png new file mode 100644 index 0000000000..739045a77f Binary files /dev/null and b/plugins/devtools/docs/devtools-info-tab.png differ diff --git a/plugins/devtools/package.json b/plugins/devtools/package.json new file mode 100644 index 0000000000..779886749c --- /dev/null +++ b/plugins/devtools/package.json @@ -0,0 +1,64 @@ +{ + "name": "@backstage/plugin-devtools", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/devtools" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-devtools-common": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", + "@backstage/theme": "workspace:^", + "@backstage/types": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.57", + "react-json-view": "^1.21.3", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/node": "*", + "cross-fetch": "^3.1.5", + "msw": "^0.47.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/devtools/src/api/DevToolsApi.ts b/plugins/devtools/src/api/DevToolsApi.ts new file mode 100644 index 0000000000..ff58a4ea16 --- /dev/null +++ b/plugins/devtools/src/api/DevToolsApi.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2022 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 { createApiRef } from '@backstage/core-plugin-api'; +import { + ConfigInfo, + DevToolsInfo, + ExternalDependency, +} from '@backstage/plugin-devtools-common'; + +export const devToolsApiRef = createApiRef({ + id: 'plugin.devtools.service', +}); + +export interface DevToolsApi { + getConfig(): Promise; + getExternalDependencies(): Promise; + getInfo(): Promise; +} diff --git a/plugins/devtools/src/api/DevToolsClient.ts b/plugins/devtools/src/api/DevToolsClient.ts new file mode 100644 index 0000000000..0be8294b3f --- /dev/null +++ b/plugins/devtools/src/api/DevToolsClient.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2022 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 { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + ConfigInfo, + DevToolsInfo, + ExternalDependency, +} from '@backstage/plugin-devtools-common'; +import { ResponseError } from '@backstage/errors'; +import { DevToolsApi } from './DevToolsApi'; + +export class DevToolsClient implements DevToolsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + public constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + } + + public async getConfig(): Promise { + const urlSegment = 'config'; + + const configInfo = await this.get(urlSegment); + return configInfo; + } + + public async getExternalDependencies(): Promise< + ExternalDependency[] | undefined + > { + const urlSegment = 'external-dependencies'; + + const externalDependencies = await this.get< + ExternalDependency[] | undefined + >(urlSegment); + return externalDependencies; + } + + public async getInfo(): Promise { + const urlSegment = 'info'; + + const info = await this.get(urlSegment); + return info; + } + + private async get(path: string): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('devtools')}/`; + const url = new URL(path, baseUrl); + + const { token } = await this.identityApi.getCredentials(); + const response = await fetch(url.toString(), { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json() as Promise; + } +} diff --git a/plugins/devtools/src/api/index.ts b/plugins/devtools/src/api/index.ts new file mode 100644 index 0000000000..6bea0a07aa --- /dev/null +++ b/plugins/devtools/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 * from './DevToolsApi'; +export * from './DevToolsClient'; diff --git a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx new file mode 100644 index 0000000000..66129b5dae --- /dev/null +++ b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2022 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 { Progress, WarningPanel } from '@backstage/core-components'; +import { + Box, + createStyles, + makeStyles, + Paper, + Theme, + Typography, + useTheme, +} from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import ReactJson from 'react-json-view'; +import { useConfig } from '../../../hooks'; +import { ConfigError } from '@backstage/plugin-devtools-common'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + warningStyle: { + paddingBottom: theme.spacing(2), + }, + paperStyle: { + padding: theme.spacing(2), + }, + }), +); + +export const WarningContent = ({ error }: { error: ConfigError }) => { + if (!error.messages) { + return {error.message}; + } + + const messages = error.messages as string[]; + + return ( + + {messages.map(message => ( + {message} + ))} + + ); +}; + +/** @public */ +export const ConfigContent = () => { + const classes = useStyles(); + const theme = useTheme(); + const { configInfo, loading, error } = useConfig(); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + if (!configInfo) { + return Unable to load config data; + } + + return ( + + {configInfo && configInfo.error && ( + + + + + + )} + + + + + ); +}; diff --git a/plugins/devtools/src/components/Content/ConfigContent/index.ts b/plugins/devtools/src/components/Content/ConfigContent/index.ts new file mode 100644 index 0000000000..b1cc671de8 --- /dev/null +++ b/plugins/devtools/src/components/Content/ConfigContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { ConfigContent } from './ConfigContent'; diff --git a/plugins/devtools/src/components/Content/ExternalDependenciesContent/ExternalDependenciesContent.tsx b/plugins/devtools/src/components/Content/ExternalDependenciesContent/ExternalDependenciesContent.tsx new file mode 100644 index 0000000000..a5dd825fe5 --- /dev/null +++ b/plugins/devtools/src/components/Content/ExternalDependenciesContent/ExternalDependenciesContent.tsx @@ -0,0 +1,140 @@ +/* + * Copyright 2022 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 { + Progress, + StatusError, + StatusOK, + StatusWarning, + Table, + TableColumn, +} from '@backstage/core-components'; +import { ExternalDependency } from '@backstage/plugin-devtools-common'; +import { + Box, + createStyles, + Grid, + makeStyles, + Paper, + Theme, + Typography, +} from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import { useExternalDependencies } from '../../../hooks'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + paperStyle: { + padding: theme.spacing(2), + }, + }), +); + +export const getExternalDependencyStatus = ( + result: Partial | undefined, +) => { + switch (result?.status) { + case 'Healthy': + return ( + + {result.status} + + ); + case 'Unhealthy': + return ( + + {`${result.status}`} + + ); + case undefined: + default: + return ( + + Unknown + + ); + } +}; + +const columns: TableColumn[] = [ + { + title: 'Name', + width: 'auto', + field: 'name', + }, + { + title: 'Target', + width: 'auto', + field: 'target', + }, + { + title: 'Type', + width: 'auto', + field: 'type', + }, + { + title: 'Status', + width: 'auto', + render: (row: Partial) => ( + + + + {getExternalDependencyStatus(row)} + + + {row.error && {row.error}} + + ), + }, +]; + +/** @public */ +export const ExternalDependenciesContent = () => { + const classes = useStyles(); + const { externalDependencies, loading, error } = useExternalDependencies(); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + if (!externalDependencies || externalDependencies.length === 0) { + return ( + + + No external dependencies found + + + ); + } + + return ( + + ); +}; diff --git a/plugins/devtools/src/components/Content/ExternalDependenciesContent/index.ts b/plugins/devtools/src/components/Content/ExternalDependenciesContent/index.ts new file mode 100644 index 0000000000..bf747f3065 --- /dev/null +++ b/plugins/devtools/src/components/Content/ExternalDependenciesContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { ExternalDependenciesContent } from './ExternalDependenciesContent'; diff --git a/plugins/devtools/src/components/Content/InfoContent/BackstageLogoIcon.tsx b/plugins/devtools/src/components/Content/InfoContent/BackstageLogoIcon.tsx new file mode 100644 index 0000000000..32f35600f2 --- /dev/null +++ b/plugins/devtools/src/components/Content/InfoContent/BackstageLogoIcon.tsx @@ -0,0 +1,25 @@ +/* + * Copyright 2022 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 { SvgIcon, SvgIconProps } from '@material-ui/core'; + +import React from 'react'; + +export const BackstageLogoIcon = (props: SvgIconProps) => ( + + + +); diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx new file mode 100644 index 0000000000..7a0ebb3e06 --- /dev/null +++ b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2022 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 { Progress } from '@backstage/core-components'; +import { + Avatar, + Box, + createStyles, + Divider, + List, + ListItem, + ListItemAvatar, + ListItemText, + makeStyles, + Paper, + Theme, +} from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import { useInfo } from '../../../hooks'; +import { InfoDependenciesTable } from './InfoDependenciesTable'; +import DescriptionIcon from '@material-ui/icons/Description'; +import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard'; +import { BackstageLogoIcon } from './BackstageLogoIcon'; +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import { DevToolsInfo } from '@backstage/plugin-devtools-common'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + paperStyle: { + marginBottom: theme.spacing(2), + }, + flexContainer: { + display: 'flex', + flexDirection: 'row', + padding: 0, + }, + copyButton: { + float: 'left', + margin: theme.spacing(2), + }, + }), +); + +const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => { + if (about) { + let formatted = `OS: ${about.operatingSystem}\nnode: ${about.nodeJsVersion}\nbackstage: ${about.backstageVersion}\nDependencies:\n`; + const deps = about.dependencies; + for (const key in deps) { + if (Object.prototype.hasOwnProperty.call(deps, key)) { + formatted = `${formatted} ${deps[key].name}: ${deps[key].versions}\n`; + } + } + window.navigator.clipboard.writeText(formatted); + } +}; + +/** @public */ +export const InfoContent = () => { + const classes = useStyles(); + const { about, loading, error } = useInfo(); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + copyToClipboard({ about }); + }} + className={classes.copyButton} + > + + + + + + + + + + + + ); +}; diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoDependenciesTable.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoDependenciesTable.tsx new file mode 100644 index 0000000000..e1ab59f279 --- /dev/null +++ b/plugins/devtools/src/components/Content/InfoContent/InfoDependenciesTable.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2022 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 { Table, TableColumn } from '@backstage/core-components'; +import { PackageDependency } from '@backstage/plugin-devtools-common'; + +import React from 'react'; + +const columns: TableColumn[] = [ + { + title: 'Name', + width: 'auto', + field: 'name', + defaultSort: 'asc', + }, + { + title: 'Versions', + width: 'auto', + field: 'versions', + }, +]; + +export const InfoDependenciesTable = ({ + infoDependencies, +}: { + infoDependencies: PackageDependency[] | undefined; +}) => { + return ( +
+ ); +}; diff --git a/plugins/devtools/src/components/Content/InfoContent/index.ts b/plugins/devtools/src/components/Content/InfoContent/index.ts new file mode 100644 index 0000000000..6d5bad53f8 --- /dev/null +++ b/plugins/devtools/src/components/Content/InfoContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { InfoContent } from './InfoContent'; diff --git a/plugins/devtools/src/components/Content/index.ts b/plugins/devtools/src/components/Content/index.ts new file mode 100644 index 0000000000..f5bd64f7c0 --- /dev/null +++ b/plugins/devtools/src/components/Content/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 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 * from './ConfigContent'; +export * from './InfoContent'; +export * from './ExternalDependenciesContent'; diff --git a/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx b/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx new file mode 100644 index 0000000000..a5e4f2439b --- /dev/null +++ b/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2022 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 { + devToolsConfigReadPermission, + devToolsInfoReadPermission, +} from '@backstage/plugin-devtools-common'; + +import { ConfigContent } from '../Content/ConfigContent'; +import { DevToolsLayout } from '../DevToolsLayout'; +import { InfoContent } from '../Content/InfoContent'; +import React from 'react'; +import { RequirePermission } from '@backstage/plugin-permission-react'; + +/** @public */ +export const DefaultDevToolsPage = () => ( + + + + + + + + + + + + +); diff --git a/plugins/devtools/src/components/DefaultDevToolsPage/index.ts b/plugins/devtools/src/components/DefaultDevToolsPage/index.ts new file mode 100644 index 0000000000..133dcf6e3a --- /dev/null +++ b/plugins/devtools/src/components/DefaultDevToolsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { DefaultDevToolsPage } from './DefaultDevToolsPage'; diff --git a/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx b/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx new file mode 100644 index 0000000000..12cd3f7666 --- /dev/null +++ b/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2022 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 { Header, Page, RoutedTabs } from '@backstage/core-components'; +import { + attachComponentData, + useElementFilter, +} from '@backstage/core-plugin-api'; +import { TabProps } from '@material-ui/core'; +import { default as React } from 'react'; + +/** @public */ +export type SubRoute = { + path: string; + title: string; + children: JSX.Element; + tabProps?: TabProps; +}; + +const dataKey = 'plugin.devtools.devtoolsLayoutRoute'; + +const Route: (props: SubRoute) => null = () => null; +attachComponentData(Route, dataKey, true); + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +/** @public */ +export type DevToolsLayoutProps = { + children?: React.ReactNode; +}; + +/** + * DevTools is a compound component, which allows you to define a custom layout + * + * @example + * ```jsx + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + * @public + */ +export const DevToolsLayout = ({ children }: DevToolsLayoutProps) => { + const routes = useElementFilter(children, elements => + elements + .selectByComponentData({ + key: dataKey, + withStrictError: + 'Child of DevToolsLayout must be an DevToolsLayout.Route', + }) + .getElements() + .map(child => child.props), + ); + + return ( + +
+ + + ); +}; + +DevToolsLayout.Route = Route; diff --git a/plugins/devtools/src/components/DevToolsLayout/index.tsx b/plugins/devtools/src/components/DevToolsLayout/index.tsx new file mode 100644 index 0000000000..0bef371b05 --- /dev/null +++ b/plugins/devtools/src/components/DevToolsLayout/index.tsx @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 type { DevToolsLayoutProps, SubRoute } from './DevToolsLayout'; +export { DevToolsLayout } from './DevToolsLayout'; diff --git a/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx b/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx new file mode 100644 index 0000000000..880efed68c --- /dev/null +++ b/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx @@ -0,0 +1,25 @@ +/* + * Copyright 2022 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 { useOutlet } from 'react-router-dom'; +import { DefaultDevToolsPage } from '../DefaultDevToolsPage'; + +export const DevToolsPage = () => { + const outlet = useOutlet(); + + return <>{outlet || }; +}; diff --git a/plugins/devtools/src/components/DevToolsPage/index.ts b/plugins/devtools/src/components/DevToolsPage/index.ts new file mode 100644 index 0000000000..88d8bc06fa --- /dev/null +++ b/plugins/devtools/src/components/DevToolsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { DevToolsPage } from './DevToolsPage'; diff --git a/plugins/devtools/src/components/index.ts b/plugins/devtools/src/components/index.ts new file mode 100644 index 0000000000..9938866bcc --- /dev/null +++ b/plugins/devtools/src/components/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 * from './Content'; +export * from './DevToolsLayout'; diff --git a/plugins/devtools/src/hooks/index.ts b/plugins/devtools/src/hooks/index.ts new file mode 100644 index 0000000000..bb2e83d06f --- /dev/null +++ b/plugins/devtools/src/hooks/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 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 { useConfig } from './useConfig'; +export { useExternalDependencies } from './useExternalDependencies'; +export { useInfo } from './useInfo'; diff --git a/plugins/devtools/src/hooks/useConfig.ts b/plugins/devtools/src/hooks/useConfig.ts new file mode 100644 index 0000000000..6703e7ea59 --- /dev/null +++ b/plugins/devtools/src/hooks/useConfig.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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 { devToolsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { ConfigInfo } from '@backstage/plugin-devtools-common'; + +export function useConfig(): { + configInfo?: ConfigInfo; + loading: boolean; + error?: Error; +} { + const api = useApi(devToolsApiRef); + const { value, loading, error } = useAsync(() => { + return api.getConfig(); + }, [api]); + + return { + configInfo: value, + loading, + error, + }; +} diff --git a/plugins/devtools/src/hooks/useExternalDependencies.ts b/plugins/devtools/src/hooks/useExternalDependencies.ts new file mode 100644 index 0000000000..df2f3e193b --- /dev/null +++ b/plugins/devtools/src/hooks/useExternalDependencies.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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 { devToolsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { ExternalDependency } from '@backstage/plugin-devtools-common'; + +export function useExternalDependencies(): { + externalDependencies?: ExternalDependency[]; + loading: boolean; + error?: Error; +} { + const api = useApi(devToolsApiRef); + const { value, loading, error } = useAsync(() => { + return api.getExternalDependencies(); + }, [api]); + + return { + externalDependencies: value, + loading, + error, + }; +} diff --git a/plugins/devtools/src/hooks/useInfo.ts b/plugins/devtools/src/hooks/useInfo.ts new file mode 100644 index 0000000000..ad700cb3fe --- /dev/null +++ b/plugins/devtools/src/hooks/useInfo.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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 { devToolsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { DevToolsInfo } from '@backstage/plugin-devtools-common'; + +export function useInfo(): { + about?: DevToolsInfo; + loading: boolean; + error?: Error; +} { + const api = useApi(devToolsApiRef); + const { value, loading, error } = useAsync(() => { + return api.getInfo(); + }, [api]); + + return { + about: value, + loading, + error, + }; +} diff --git a/plugins/devtools/src/index.ts b/plugins/devtools/src/index.ts new file mode 100644 index 0000000000..576046ca1b --- /dev/null +++ b/plugins/devtools/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 { devToolsPlugin, DevToolsPage } from './plugin'; +export * from './components'; diff --git a/plugins/devtools/src/plugin.test.ts b/plugins/devtools/src/plugin.test.ts new file mode 100644 index 0000000000..62f4ae5a61 --- /dev/null +++ b/plugins/devtools/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2022 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 { devToolsPlugin } from './plugin'; + +describe('devtools', () => { + it('should export plugin', () => { + expect(devToolsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/devtools/src/plugin.ts b/plugins/devtools/src/plugin.ts new file mode 100644 index 0000000000..df2aca3447 --- /dev/null +++ b/plugins/devtools/src/plugin.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2022 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 { + createApiFactory, + createPlugin, + createRoutableExtension, + discoveryApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { devToolsApiRef, DevToolsClient } from './api'; + +import { rootRouteRef } from './routes'; + +/** @public */ +export const devToolsPlugin = createPlugin({ + id: 'devtools', + apis: [ + createApiFactory({ + api: devToolsApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new DevToolsClient({ discoveryApi, identityApi }), + }), + ], + routes: { + root: rootRouteRef, + }, +}); + +/** @public */ +export const DevToolsPage = devToolsPlugin.provide( + createRoutableExtension({ + name: 'DevToolsPage', + component: () => + import('./components/DevToolsPage').then(m => m.DevToolsPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/devtools/src/routes.ts b/plugins/devtools/src/routes.ts new file mode 100644 index 0000000000..7825f928be --- /dev/null +++ b/plugins/devtools/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2022 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 { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'devtools', +}); diff --git a/plugins/devtools/src/setupTests.ts b/plugins/devtools/src/setupTests.ts new file mode 100644 index 0000000000..7a459ed24e --- /dev/null +++ b/plugins/devtools/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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/yarn.lock b/yarn.lock index 0bd97d81f8..d69acf7aab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3384,7 +3384,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.21.0 resolution: "@babel/runtime@npm:7.21.0" dependencies: @@ -6142,6 +6142,86 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-devtools-backend@workspace:^, @backstage/plugin-devtools-backend@workspace:plugins/devtools-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-devtools-backend@workspace:plugins/devtools-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/cli-common": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/config-loader": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-devtools-common": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-node": "workspace:^" + "@backstage/types": "workspace:^" + "@manypkg/get-packages": ^1.1.3 + "@types/express": "*" + "@types/minimist": ^1.2.0 + "@types/ping": ^0.4.1 + "@types/supertest": ^2.0.8 + "@types/yarnpkg__lockfile": ^1.1.4 + "@yarnpkg/lockfile": ^1.1.0 + "@yarnpkg/parsers": ^3.0.0-rc.4 + express: ^4.18.1 + express-promise-router: ^4.1.0 + fs-extra: ^10.0.0 + lodash: ^4.17.21 + msw: ^0.47.0 + node-fetch: ^2.6.7 + ping: ^0.4.1 + semver: ^7.3.2 + supertest: ^6.2.4 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-devtools-common@workspace:^, @backstage/plugin-devtools-common@workspace:plugins/devtools-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-devtools-common@workspace:plugins/devtools-common" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/types": "workspace:^" + languageName: unknown + linkType: soft + +"@backstage/plugin-devtools@workspace:^, @backstage/plugin-devtools@workspace:plugins/devtools": + version: 0.0.0-use.local + resolution: "@backstage/plugin-devtools@workspace:plugins/devtools" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-devtools-common": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@backstage/types": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.57 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + "@types/node": "*" + cross-fetch: ^3.1.5 + msw: ^0.47.0 + react-json-view: ^1.21.3 + react-use: ^17.2.4 + peerDependencies: + "@types/react": ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-dynatrace@workspace:^, @backstage/plugin-dynatrace@workspace:plugins/dynatrace": version: 0.0.0-use.local resolution: "@backstage/plugin-dynatrace@workspace:plugins/dynatrace" @@ -16383,6 +16463,13 @@ __metadata: languageName: node linkType: hard +"@types/ping@npm:^0.4.1": + version: 0.4.1 + resolution: "@types/ping@npm:0.4.1" + checksum: 9b94837fe66df70558c5a42b0e0c8371b4950ab56b96c42c8df809ff2cf52477dd0a7e01d2e6b38af8bb6683b3dcb54587960b96b4b1f3d40fdb529aea348ad0 + languageName: node + linkType: hard + "@types/pluralize@npm:^0.0.29": version: 0.0.29 resolution: "@types/pluralize@npm:0.0.29" @@ -18784,6 +18871,13 @@ __metadata: languageName: node linkType: hard +"base16@npm:^1.0.0": + version: 1.0.0 + resolution: "base16@npm:1.0.0" + checksum: 0cd449a2db0f0f957e4b6b57e33bc43c9e20d4f1dd744065db94b5da35e8e71fa4dc4bc7a901e59a84d5f8b6936e3c520e2471787f667fc155fb0f50d8540f5d + languageName: node + linkType: hard + "base64-js@npm:^1.0.2, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -20882,7 +20976,7 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:3.1.5, cross-fetch@npm:^3.0.4, cross-fetch@npm:^3.1.3, cross-fetch@npm:^3.1.5": +"cross-fetch@npm:3.1.5, cross-fetch@npm:^3.1.3, cross-fetch@npm:^3.1.5": version: 3.1.5 resolution: "cross-fetch@npm:3.1.5" dependencies: @@ -23618,6 +23712,7 @@ __metadata: "@backstage/plugin-cloudbuild": "workspace:^" "@backstage/plugin-code-coverage": "workspace:^" "@backstage/plugin-cost-insights": "workspace:^" + "@backstage/plugin-devtools": "workspace:^" "@backstage/plugin-dynatrace": "workspace:^" "@backstage/plugin-entity-feedback": "workspace:^" "@backstage/plugin-explore": "workspace:^" @@ -23741,6 +23836,7 @@ __metadata: "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/plugin-code-coverage-backend": "workspace:^" + "@backstage/plugin-devtools-backend": "workspace:^" "@backstage/plugin-entity-feedback-backend": "workspace:^" "@backstage/plugin-events-backend": "workspace:^" "@backstage/plugin-events-node": "workspace:^" @@ -24222,6 +24318,15 @@ __metadata: languageName: node linkType: hard +"fbemitter@npm:^3.0.0": + version: 3.0.0 + resolution: "fbemitter@npm:3.0.0" + dependencies: + fbjs: ^3.0.0 + checksum: 069690b8cdff3521ade3c9beb92ba0a38d818a86ef36dff8690e66749aef58809db4ac0d6938eb1cacea2dbef5f2a508952d455669590264cdc146bbe839f605 + languageName: node + linkType: hard + "fbjs-css-vars@npm:^1.0.0": version: 1.0.2 resolution: "fbjs-css-vars@npm:1.0.2" @@ -24244,18 +24349,18 @@ __metadata: languageName: node linkType: hard -"fbjs@npm:^3.0.0": - version: 3.0.0 - resolution: "fbjs@npm:3.0.0" +"fbjs@npm:^3.0.0, fbjs@npm:^3.0.1": + version: 3.0.4 + resolution: "fbjs@npm:3.0.4" dependencies: - cross-fetch: ^3.0.4 + cross-fetch: ^3.1.5 fbjs-css-vars: ^1.0.0 loose-envify: ^1.0.0 object-assign: ^4.1.0 promise: ^7.1.1 setimmediate: ^1.0.5 - ua-parser-js: ^0.7.18 - checksum: 85ec57d8dbeddd7c82bf8f111a3c7de1abc1f4d7c603d6ccbcc1ec8dce35ff5b7a113dd34acbf7930093e5533c37a2298a92d342077f967bef34dc7cf2f3f07e + ua-parser-js: ^0.7.30 + checksum: 8b23a3550fcda8a9109fca9475a3416590c18bb6825ea884192864ed686f67fcd618e308a140c9e5444fbd0168732e1ff3c092ba3d0c0ae1768969f32ba280c7 languageName: node linkType: hard @@ -24499,6 +24604,18 @@ __metadata: languageName: node linkType: hard +"flux@npm:^4.0.1": + version: 4.0.4 + resolution: "flux@npm:4.0.4" + dependencies: + fbemitter: ^3.0.0 + fbjs: ^3.0.1 + peerDependencies: + react: ^15.0.2 || ^16.0.0 || ^17.0.0 + checksum: 8fa5c2f9322258de3e331f67c6f1078a7f91c4dec9dbe8a54c4b8a80eed19a4f91889028b768668af4a796e8f2ee75e461e1571b8615432a3920ae95cc4ff794 + languageName: node + linkType: hard + "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -29439,6 +29556,13 @@ __metadata: languageName: node linkType: hard +"lodash.curry@npm:^4.0.1": + version: 4.1.1 + resolution: "lodash.curry@npm:4.1.1" + checksum: 9192b70fe7df4d1ff780c0260bee271afa9168c93fe4fa24bc861900240531b59781b5fdaadf4644fea8f4fbcd96f0700539ab294b579ffc1022c6c15dcc462a + languageName: node + linkType: hard + "lodash.debounce@npm:^4, lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -29474,6 +29598,13 @@ __metadata: languageName: node linkType: hard +"lodash.flow@npm:^3.3.0": + version: 3.5.0 + resolution: "lodash.flow@npm:3.5.0" + checksum: a9a62ad344e3c5a1f42bc121da20f64dd855aaafecee24b1db640f29b88bd165d81c37ff7e380a7191de6f70b26f5918abcebbee8396624f78f3618a0b18634c + languageName: node + linkType: hard + "lodash.get@npm:^4.4.2": version: 4.4.2 resolution: "lodash.get@npm:4.4.2" @@ -31083,6 +31214,41 @@ __metadata: languageName: node linkType: hard +"msw@npm:^0.47.0": + version: 0.47.4 + resolution: "msw@npm:0.47.4" + dependencies: + "@mswjs/cookies": ^0.2.2 + "@mswjs/interceptors": ^0.17.5 + "@open-draft/until": ^1.0.3 + "@types/cookie": ^0.4.1 + "@types/js-levenshtein": ^1.1.1 + chalk: 4.1.1 + chokidar: ^3.4.2 + cookie: ^0.4.2 + graphql: ^15.0.0 || ^16.0.0 + headers-polyfill: ^3.1.0 + inquirer: ^8.2.0 + is-node-process: ^1.0.1 + js-levenshtein: ^1.1.6 + node-fetch: ^2.6.7 + outvariant: ^1.3.0 + path-to-regexp: ^6.2.0 + statuses: ^2.0.0 + strict-event-emitter: ^0.2.6 + type-fest: ^2.19.0 + yargs: ^17.3.1 + peerDependencies: + typescript: ">= 4.2.x <= 4.8.x" + peerDependenciesMeta: + typescript: + optional: true + bin: + msw: cli/index.js + checksum: 10ff632641d40384d6622abf4df6399e4ae649db0f676b5d1ee2d0a515ec96f33abe9d4fecba08cdba4b2e43255af419da9eefc020d40a7e10669d0906457197 + languageName: node + linkType: hard + "msw@npm:^0.49.0": version: 0.49.3 resolution: "msw@npm:0.49.3" @@ -33154,6 +33320,13 @@ __metadata: languageName: node linkType: hard +"ping@npm:^0.4.1": + version: 0.4.4 + resolution: "ping@npm:0.4.4" + checksum: cab10af309312e3a6822eccb7f323f0c9a1e17ef5895954114e31b6a1cced5e2ced3563990b79c66d074372e75aa4c846d9001120ee296b365de6a2adef5b8f9 + languageName: node + linkType: hard + "pinkie-promise@npm:^2.0.0": version: 2.0.1 resolution: "pinkie-promise@npm:2.0.1" @@ -34242,6 +34415,13 @@ __metadata: languageName: node linkType: hard +"pure-color@npm:^1.2.0": + version: 1.3.0 + resolution: "pure-color@npm:1.3.0" + checksum: 646d8bed6e6eab89affdd5e2c11f607a85b631a7fb03c061dfa658eb4dc4806881a15feed2ac5fd8c0bad8c00c632c640d5b1cb8b9a972e6e947393a1329371b + languageName: node + linkType: hard + "pvtsutils@npm:^1.3.2": version: 1.3.2 resolution: "pvtsutils@npm:1.3.2" @@ -34474,6 +34654,18 @@ __metadata: languageName: node linkType: hard +"react-base16-styling@npm:^0.6.0": + version: 0.6.0 + resolution: "react-base16-styling@npm:0.6.0" + dependencies: + base16: ^1.0.0 + lodash.curry: ^4.0.1 + lodash.flow: ^3.3.0 + pure-color: ^1.2.0 + checksum: 00a12dddafc8a9025cca933b0dcb65fca41c81fa176d1fc3a6a9d0242127042e2c0a604f4c724a3254dd2c6aeb5ef55095522ff22f5462e419641c1341a658e4 + languageName: node + linkType: hard + "react-beautiful-dnd@npm:^13.0.0": version: 13.0.0 resolution: "react-beautiful-dnd@npm:13.0.0" @@ -34743,6 +34935,21 @@ __metadata: languageName: node linkType: hard +"react-json-view@npm:^1.21.3": + version: 1.21.3 + resolution: "react-json-view@npm:1.21.3" + dependencies: + flux: ^4.0.1 + react-base16-styling: ^0.6.0 + react-lifecycles-compat: ^3.0.4 + react-textarea-autosize: ^8.3.2 + peerDependencies: + react: ^17.0.0 || ^16.3.0 || ^15.5.4 + react-dom: ^17.0.0 || ^16.3.0 || ^15.5.4 + checksum: 5718bcd9210ad5b06eb9469cf8b9b44be9498845a7702e621343618e8251f26357e6e1c865532cf170db6165df1cb30202787e057309d8848c220bc600ec0d1a + languageName: node + linkType: hard + "react-lifecycles-compat@npm:^3.0.2, react-lifecycles-compat@npm:^3.0.4": version: 3.0.4 resolution: "react-lifecycles-compat@npm:3.0.4" @@ -35010,6 +35217,19 @@ __metadata: languageName: node linkType: hard +"react-textarea-autosize@npm:^8.3.2": + version: 8.4.1 + resolution: "react-textarea-autosize@npm:8.4.1" + dependencies: + "@babel/runtime": ^7.20.13 + use-composed-ref: ^1.3.0 + use-latest: ^1.2.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: b200437cd68938c23b13944fe6fdfeb32a6d949ac88588307f14d6fcdaba3044b8c7d8e239851b081f2101d433b93d4cf5aa027543b170b84f2a0cbe6fc9093f + languageName: node + linkType: hard + "react-transition-group@npm:2.9.0, react-transition-group@npm:^2.2.1": version: 2.9.0 resolution: "react-transition-group@npm:2.9.0" @@ -37374,7 +37594,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:2.0.1": +"statuses@npm:2.0.1, statuses@npm:^2.0.0": version: 2.0.1 resolution: "statuses@npm:2.0.1" checksum: 18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb @@ -37476,12 +37696,12 @@ __metadata: languageName: node linkType: hard -"strict-event-emitter@npm:^0.2.4": - version: 0.2.7 - resolution: "strict-event-emitter@npm:0.2.7" +"strict-event-emitter@npm:^0.2.4, strict-event-emitter@npm:^0.2.6": + version: 0.2.8 + resolution: "strict-event-emitter@npm:0.2.8" dependencies: events: ^3.3.0 - checksum: 111691e7d3fce0810586ccd8e8234af883ad3b121ef69091c7e260c32299d1ba085a95238ad09b43478bc5e9e80370f2fcb8114716e343be6f44bfc08fab4142 + checksum: 6ac06fe72a6ee6ae64d20f1dd42838ea67342f1b5f32b03b3050d73ee6ecee44b4d5c4ed2965a7154b47991e215f373d4e789e2b2be2769cd80e356126c2ca53 languageName: node linkType: hard @@ -39046,7 +39266,7 @@ __metadata: languageName: node linkType: hard -"ua-parser-js@npm:^0.7.18, ua-parser-js@npm:^0.7.30": +"ua-parser-js@npm:^0.7.30": version: 0.7.33 resolution: "ua-parser-js@npm:0.7.33" checksum: 1510e9ec26fcaf0d8c6ae8f1078a8230e8816f083e1b5f453ea19d06b8ef2b8a596601c92148fd41899e8b3e5f83fa69c42332bd5729b931a721040339831696 @@ -39446,6 +39666,15 @@ __metadata: languageName: node linkType: hard +"use-composed-ref@npm:^1.3.0": + version: 1.3.0 + resolution: "use-composed-ref@npm:1.3.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: f771cbadfdc91e03b7ab9eb32d0fc0cc647755711801bf507e891ad38c4bbc5f02b2509acadf9c965ec9c5f2f642fd33bdfdfb17b0873c4ad0a9b1f5e5e724bf + languageName: node + linkType: hard + "use-deep-compare-effect@npm:^1.8.1": version: 1.8.1 resolution: "use-deep-compare-effect@npm:1.8.1" @@ -39468,6 +39697,32 @@ __metadata: languageName: node linkType: hard +"use-isomorphic-layout-effect@npm:^1.1.1": + version: 1.1.2 + resolution: "use-isomorphic-layout-effect@npm:1.1.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: a6532f7fc9ae222c3725ff0308aaf1f1ddbd3c00d685ef9eee6714fd0684de5cb9741b432fbf51e61a784e2955424864f7ea9f99734a02f237b17ad3e18ea5cb + languageName: node + linkType: hard + +"use-latest@npm:^1.2.1": + version: 1.2.1 + resolution: "use-latest@npm:1.2.1" + dependencies: + use-isomorphic-layout-effect: ^1.1.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: ed3f2ddddf6f21825e2ede4c2e0f0db8dcce5129802b69d1f0575fc1b42380436e8c76a6cd885d4e9aa8e292e60fb8b959c955f33c6a9123b83814a1a1875367 + languageName: node + linkType: hard + "use-memo-one@npm:^1.1.1": version: 1.1.1 resolution: "use-memo-one@npm:1.1.1"