├── CODEOWNERS ├── .eslintignore ├── backstage.json ├── .eslintrc.js ├── .prettierignore ├── .yarnrc.yml ├── img └── tsLogo.png ├── plugins ├── time-saver │ ├── .eslintrc.js │ ├── docs │ │ ├── tsByTeam.png │ │ ├── tsAllStats.png │ │ ├── tsAllStats2.png │ │ ├── tsByTeam2.png │ │ └── tsByTemplate.png │ ├── config.d.ts │ ├── src │ │ ├── setupTests.ts │ │ ├── index.ts │ │ ├── components │ │ │ ├── TimeSaverPageComponent │ │ │ │ └── index.ts │ │ │ ├── utils.ts │ │ │ ├── TimeSaverHeader │ │ │ │ └── TimeSaverHeaderComponent.tsx │ │ │ ├── Gauge │ │ │ │ ├── TeamsGauge.tsx │ │ │ │ ├── TemplatesGauge.tsx │ │ │ │ ├── Gauge.tsx │ │ │ │ ├── TemplatesTaskCountGauge.tsx │ │ │ │ ├── TimeSavedGauge.tsx │ │ │ │ └── EmptyDbContent.tsx │ │ │ ├── TemplateAutocompleteComponent │ │ │ │ └── TemplateAutocompleteComponent.tsx │ │ │ ├── TemplateTaskAutocompleteComponent │ │ │ │ └── TemplateTaskAutocompleteComponent.tsx │ │ │ ├── TeamSelectorComponent │ │ │ │ └── TeamSelectorComponent.tsx │ │ │ ├── GroupDivisionPieChartComponent │ │ │ │ └── GroupDivisionPieChartComponent.tsx │ │ │ ├── BarChartComponent │ │ │ │ └── BarChartComponent.tsx │ │ │ ├── ByTeamBarCharComponent │ │ │ │ └── ByTeamBarChartComponent.tsx │ │ │ ├── ByTemplateBarCharComponent │ │ │ │ └── ByTemplateBarChartComponent.tsx │ │ │ ├── AllStatsBarChartComponent │ │ │ │ └── AllStatsBarChartComponent.tsx │ │ │ ├── TeamWiseTimeSummaryLinearComponent │ │ │ │ └── TeamWiseTimeSummaryLinearComponent.tsx │ │ │ ├── Table │ │ │ │ └── StatsTable.tsx │ │ │ ├── TemplateWiseDailyTimeLinearComponent │ │ │ │ └── TemplateWiseWiseDailyTimeLinearComponent.tsx │ │ │ ├── TeamWiseDailyTimeLinearComponent │ │ │ │ └── TeamWiseDailyTimeLinearComponent.tsx │ │ │ └── TemplateWiseTimeSummaryLinearComponent │ │ │ │ └── TemplateWiseTimeSummaryLinearComponent.tsx │ │ ├── routes.ts │ │ ├── plugin.test.ts │ │ └── plugin.ts │ ├── dev │ │ └── index.tsx │ ├── CHANGELOG.md │ ├── package.json │ └── README.md ├── time-saver-backend │ ├── .eslintrc.js │ ├── src │ │ ├── api │ │ │ ├── defaultValues.ts │ │ │ └── scaffolderClient.ts │ │ ├── setupTests.ts │ │ ├── index.ts │ │ ├── run.ts │ │ ├── utils.ts │ │ ├── timeSaver │ │ │ ├── scheduler.ts │ │ │ └── handler.ts │ │ ├── database │ │ │ ├── types.ts │ │ │ ├── ScaffolderDatabase.ts │ │ │ └── mappers.ts │ │ └── service │ │ │ ├── standaloneServer.ts │ │ │ ├── router.ts │ │ │ └── router.test.ts │ ├── migrations │ │ ├── test-executions.js │ │ ├── role-column.js │ │ └── init.js │ ├── CHANGELOG.md │ └── package.json ├── time-saver-common │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── src │ │ ├── setupTests.ts │ │ ├── permissions │ │ │ └── permissions.ts │ │ └── index.ts │ ├── package.json │ └── README.md └── catalog-backend-module-time-saver-processor │ ├── src │ ├── processor │ │ ├── index.ts │ │ ├── TimeSaverProcessor.ts │ │ └── TimeSaverProcessor.test.ts │ ├── index.ts │ └── module.ts │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── package.json │ └── README.md ├── scripts ├── tsconfig.json ├── .eslintrc.js ├── copyright-header.txt ├── techdocs-cli.js ├── verify-api-reference.js ├── create-release-tag.js ├── verify-changesets.js ├── assemble-manifest.js ├── generate-merge-message.js ├── check-if-release.js ├── check-docs-quality.js ├── verify-lockfile-duplicates.js ├── run-fossa.js ├── patch-release-for-pr.js └── build-plugins-report.js ├── lerna.json ├── .changeset ├── config.json └── README.md ├── .vscode └── settings.json ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ ├── build.yaml │ └── npm-publish.yaml ├── playwright.config.ts ├── app-config.yaml ├── README.md └── package.json /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tduniec @ionSurf -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | playwright.config.ts 2 | -------------------------------------------------------------------------------- /backstage.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.29.0" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist-types 3 | coverage 4 | .vscode 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.8.3.cjs 4 | -------------------------------------------------------------------------------- /img/tsLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/HEAD/img/tsLogo.png -------------------------------------------------------------------------------- /plugins/time-saver/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); 2 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); 2 | -------------------------------------------------------------------------------- /plugins/time-saver-common/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); 2 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/src/processor/index.ts: -------------------------------------------------------------------------------- 1 | export { TimeSaverProcessor } from './TimeSaverProcessor'; 2 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /plugins/time-saver/docs/tsByTeam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/HEAD/plugins/time-saver/docs/tsByTeam.png -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); 2 | -------------------------------------------------------------------------------- /plugins/time-saver/docs/tsAllStats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/HEAD/plugins/time-saver/docs/tsAllStats.png -------------------------------------------------------------------------------- /plugins/time-saver/docs/tsAllStats2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/HEAD/plugins/time-saver/docs/tsAllStats2.png -------------------------------------------------------------------------------- /plugins/time-saver/docs/tsByTeam2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/HEAD/plugins/time-saver/docs/tsByTeam2.png -------------------------------------------------------------------------------- /plugins/time-saver/docs/tsByTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/HEAD/plugins/time-saver/docs/tsByTemplate.png -------------------------------------------------------------------------------- /scripts/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@backstage/cli/config/eslint')], 3 | rules: { 4 | 'no-console': 0, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*", "plugins/*"], 3 | "npmClient": "yarn", 4 | "version": "0.1.0", 5 | "$schema": "node_modules/lerna/schemas/lerna-schema.json" 6 | } 7 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/src/index.ts: -------------------------------------------------------------------------------- 1 | /***/ 2 | /** 3 | * The time-saver-processor backend module for the catalog plugin. 4 | * 5 | * @packageDocumentation 6 | */ 7 | 8 | export { catalogModuleTimeSaverProcessor as default } from './module'; 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Default (format when you paste) 3 | "editor.formatOnPaste": true, 4 | // Default (format when you save) 5 | "editor.formatOnSave": true, 6 | "cSpell.words": [ 7 | "codemods", 8 | "CODEOWNERS", 9 | "luxon", 10 | "scaffolder", 11 | "tduniec", 12 | "techdocs" 13 | ], 14 | "git.alwaysSignOff": true, 15 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@backstage/cli/config/tsconfig.json", 3 | "include": [ 4 | "packages/*/src", 5 | "plugins/*/src", 6 | "plugins/*/dev", 7 | "plugins/*/migrations" 8 | ], 9 | "exclude": ["node_modules"], 10 | "compilerOptions": { 11 | "jsx": "react", 12 | "outDir": "dist-types", 13 | "rootDir": "." 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /plugins/time-saver-common/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @tduniec/backstage-plugin-time-saver-common 2 | 3 | ## 0.4.0 4 | 5 | ### Minor Changes 6 | 7 | - Provided dependenceis upgrade to match Backstage 1.29 version 8 | 9 | ## 0.3.0 10 | 11 | ### Minor Changes 12 | 13 | - Implemented yarn 3.x 14 | 15 | ## 0.2.0 16 | 17 | ### Minor Changes 18 | 19 | - ec4abcc: Added changelog 20 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @tduniec/backstage-plugin-catalog-backend-module-time-saver-processor 2 | 3 | ## 1.1.0 4 | 5 | ### Minor Changes 6 | 7 | - Provided dependenceis upgrade to match Backstage 1.29 version 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - Provided catalog processor backend module for Time Saver Plugin 14 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/api/defaultValues.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SAMPLE_CLASSIFICATION = { 2 | engineering: { 3 | devops: 8, 4 | development_team: 8, 5 | security: 3, 6 | }, 7 | }; 8 | 9 | export const DEFAULT_SAMPLE_TEMPLATES_TASKS = [ 10 | 'template:default/create-github-project', 11 | 'template:default/create-nodejs-service', 12 | 'template:default/create-golang-service', 13 | ]; 14 | -------------------------------------------------------------------------------- /plugins/time-saver/config.d.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | /** 3 | * @visibility frontend 4 | */ 5 | ts?: { 6 | /** 7 | * @visibility frontend 8 | */ 9 | frontend?: { 10 | /** 11 | * @visibility frontend 12 | */ 13 | table?: { 14 | /** 15 | * @visibility frontend 16 | */ 17 | showInDays?: boolean; 18 | /** 19 | * @visibility frontend 20 | */ 21 | hoursPerDay?: number; 22 | }; 23 | }; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /scripts/copyright-header.txt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright <%= YEAR %> The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | export {}; 17 | -------------------------------------------------------------------------------- /plugins/time-saver-common/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | export {}; 17 | -------------------------------------------------------------------------------- /plugins/time-saver/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import '@testing-library/jest-dom'; 17 | -------------------------------------------------------------------------------- /plugins/time-saver/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | export { TimeSaverPlugin, TimeSaverPage } from './plugin'; 17 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TimeSaverPageComponent/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | export { TimeSaverPageComponent } from './TimeSaverPageComponent'; 17 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | export * from './service/router'; 17 | export { timeSaverPlugin as default } from './service/router'; 18 | -------------------------------------------------------------------------------- /plugins/time-saver/src/routes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { createRouteRef } from '@backstage/core-plugin-api'; 17 | 18 | export const rootRouteRef = createRouteRef({ 19 | id: 'time-saver', 20 | }); 21 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/src/module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | coreServices, 3 | createBackendModule, 4 | } from '@backstage/backend-plugin-api'; 5 | import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; 6 | import { TimeSaverProcessor } from './processor/TimeSaverProcessor'; 7 | 8 | export const catalogModuleTimeSaverProcessor = createBackendModule({ 9 | pluginId: 'catalog', 10 | moduleId: 'time-saver-processor', 11 | register(reg) { 12 | reg.registerInit({ 13 | deps: { 14 | logger: coreServices.logger, 15 | catalog: catalogProcessingExtensionPoint, 16 | }, 17 | async init({ logger, catalog }) { 18 | logger.info('TimeSaver Catalog Processor ready.'); 19 | catalog.addProcessor(new TimeSaverProcessor(logger)); 20 | }, 21 | }); 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /plugins/time-saver/src/plugin.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { TimeSaverPlugin } from './plugin'; 17 | 18 | describe('time-saver', () => { 19 | it('should export plugin', () => { 20 | expect(TimeSaverPlugin).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Coverage directory generated when running tests with coverage 13 | coverage 14 | 15 | # Dependencies 16 | node_modules/ 17 | 18 | # Yarn 3 files 19 | .pnp.* 20 | .yarn/* 21 | !.yarn/patches 22 | !.yarn/plugins 23 | !.yarn/releases 24 | !.yarn/sdks 25 | !.yarn/versions 26 | 27 | # Node version directives 28 | .nvmrc 29 | 30 | # dotenv environment variables file 31 | .env 32 | .env.test 33 | 34 | # Build output 35 | dist 36 | dist-types 37 | 38 | # Temporary change files created by Vim 39 | *.swp 40 | 41 | # MkDocs build output 42 | site 43 | 44 | # Local configuration files 45 | *.local.yaml 46 | 47 | # Sensitive credentials 48 | *-credentials.yaml 49 | 50 | # vscode database functionality support files 51 | *.session.sql 52 | 53 | # E2E test reports 54 | e2e-test-report/ 55 | -------------------------------------------------------------------------------- /plugins/time-saver-common/src/permissions/permissions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { createPermission } from '@backstage/plugin-permission-common'; 17 | 18 | export const timeSaverPermission = createPermission({ 19 | name: 'timeSaver', 20 | attributes: { action: 'read' }, 21 | }); 22 | 23 | export const timeSaverPermissions = [timeSaverPermission]; 24 | -------------------------------------------------------------------------------- /plugins/time-saver-common/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Common functionalities for the time-saver plugin. 19 | * 20 | * @packageDocumentation 21 | */ 22 | 23 | /** 24 | * In this package you might for example declare types that are common 25 | * between the frontend and backend plugin packages. 26 | */ 27 | export * from './permissions/permissions'; 28 | -------------------------------------------------------------------------------- /plugins/time-saver/dev/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React from 'react'; 17 | import { createDevApp } from '@backstage/dev-utils'; 18 | import { TimeSaverPlugin, TimeSaverPage } from '../src/plugin'; 19 | 20 | createDevApp() 21 | .registerPlugin(TimeSaverPlugin) 22 | .addPage({ 23 | element: , 24 | title: 'Root Page', 25 | path: '/time-saver', 26 | }) 27 | .render(); 28 | -------------------------------------------------------------------------------- /scripts/techdocs-cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2021 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const { execSync } = require('child_process'); 19 | 20 | const args = process.argv.slice(2); 21 | 22 | execSync(`yarn -s workspace @techdocs/cli build`, { stdio: 'inherit' }); 23 | execSync(`yarn workspace @techdocs/cli link`, { stdio: 'ignore' }); 24 | execSync(`techdocs-cli ${args.join(' ')}`, { stdio: 'inherit' }); 25 | execSync(`yarn workspace @techdocs/cli unlink`, { stdio: 'ignore' }); 26 | -------------------------------------------------------------------------------- /plugins/time-saver/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @tduniec/backstage-plugin-time-saver 2 | 3 | ## 2.1.0 4 | 5 | ### Minor Changes 6 | 7 | - Adding customizable headers for TimeSaverPage 8 | - remove `Time Saved - %` prefix from All Charts Bar 9 | - Optional configuration to show Stats Table summaries in days 10 | 11 | ## 2.0.1 12 | 13 | ### Patch Changes 14 | 15 | - Adding table units to table view 16 | 17 | ## 2.0.0 18 | 19 | ### Major Changes 20 | 21 | - Added date filters component with "from-to" date fields and pre-defined period selection options. 22 | 23 | - changes provided by [@stanislavec](https://github.com/stanislavec) 24 | 25 | ## 1.4.0 26 | 27 | ### Minor Changes 28 | 29 | - Provided fixes to components accroding timesaver-backend 3.0.0 changes 30 | - Changes provided by @stanislavec 31 | 32 | ## 3.0.0 33 | 34 | ## 1.3.0 35 | 36 | ### Minor Changes 37 | 38 | - Provided dependenceis upgrade to match Backstage 1.29 version 39 | 40 | ## 1.2.0 41 | 42 | ### Minor Changes 43 | 44 | - Implemented yarn 3.x 45 | 46 | ### Patch Changes 47 | 48 | - Fixed incompatible or missing types definitions in components. 49 | 50 | ## 1.1.0 51 | 52 | ### Minor Changes 53 | 54 | - ec4abcc: Added changelog 55 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/run.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { getRootLogger } from '@backstage/backend-common'; 17 | import yn from 'yn'; 18 | import { startStandaloneServer } from './service/standaloneServer'; 19 | 20 | const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; 21 | const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); 22 | const logger = getRootLogger(); 23 | 24 | startStandaloneServer({ port, enableCors, logger }).catch(err => { 25 | logger.error(err); 26 | process.exit(1); 27 | }); 28 | 29 | process.on('SIGINT', () => { 30 | logger.info('CTRL+C pressed; exiting.'); 31 | process.exit(0); 32 | }); 33 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { IFilterDates } from './DateFiltersComponent/DateFiltersComponent'; 18 | 19 | export function getRandomColor() { 20 | const letters = '0123456789ABCDEF'; 21 | let color = '#'; 22 | for (let i = 0; i < 6; i++) { 23 | color += letters[Math.floor(Math.random() * 16)]; 24 | } 25 | return color; 26 | } 27 | 28 | export function createUrlWithDates(url: string, dates: IFilterDates) { 29 | if (!dates) return url; 30 | 31 | const [start, end] = dates; 32 | const parsedUrl = new URL(url); 33 | 34 | if (start) parsedUrl.searchParams.append('start', start!.toISODate()); 35 | if (end) parsedUrl.searchParams.set('end', end!.toISODate()); 36 | 37 | return parsedUrl.toString(); 38 | } 39 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TimeSaverHeader/TimeSaverHeaderComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React from 'react'; 17 | import { Header, HeaderLabel } from '@backstage/core-components'; 18 | 19 | export interface HeaderProps { 20 | title?: string; 21 | subtitle?: string; 22 | headerLabel?: Record; 23 | } 24 | 25 | const CustomHeader: React.FC = ({ 26 | title, 27 | subtitle, 28 | headerLabel, 29 | }) => { 30 | return ( 31 |
32 | {headerLabel && 33 | Object.entries(headerLabel).map(([labelText, value]) => ( 34 | 35 | ))} 36 |
37 | ); 38 | }; 39 | 40 | export default CustomHeader; 41 | -------------------------------------------------------------------------------- /plugins/time-saver/src/plugin.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { 17 | createPlugin, 18 | createRoutableExtension, 19 | } from '@backstage/core-plugin-api'; 20 | 21 | import { rootRouteRef } from './routes'; 22 | import { HeaderProps } from './components/TimeSaverHeader/TimeSaverHeaderComponent'; 23 | 24 | export const TimeSaverPlugin = createPlugin({ 25 | id: 'time-saver', 26 | routes: { 27 | root: rootRouteRef, 28 | }, 29 | }); 30 | 31 | export const TimeSaverPage: (props: HeaderProps) => JSX.Element = 32 | TimeSaverPlugin.provide( 33 | createRoutableExtension({ 34 | name: 'TimeSaverPage', 35 | component: () => 36 | import('./components/TimeSaverPageComponent').then( 37 | m => m.TimeSaverPageComponent, 38 | ), 39 | mountPoint: rootRouteRef, 40 | }), 41 | ); 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Test Packages 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | 15 | # # Get the yarn cache path. 16 | # - name: Get yarn cache directory path 17 | # id: yarn-cache-dir-path 18 | # run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 19 | 20 | # - name: Restore yarn cache 21 | # uses: actions/cache@v4 22 | # id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 23 | # with: 24 | # path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 25 | # key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }} 26 | # restore-keys: | 27 | # yarn-cache-folder- 28 | 29 | - name: Set up Node.js 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 18 33 | 34 | - name: Install dependencies 35 | run: yarn install --immutable 36 | 37 | - name: Run code quality checks 38 | run: | 39 | yarn tsc:full 40 | yarn lint:all 41 | yarn prettier:check 42 | 43 | - name: Run tests and build 44 | run: | 45 | yarn clean 46 | yarn tsc:full 47 | yarn build:all 48 | yarn test:all 49 | -------------------------------------------------------------------------------- /scripts/verify-api-reference.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2020 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint-disable @backstage/no-undeclared-imports */ 19 | 20 | const { resolve: resolvePath } = require('path'); 21 | const { promises: fs } = require('fs'); 22 | 23 | async function main() { 24 | const indexContent = await fs.readFile( 25 | resolvePath(__dirname, '../docs/reference/index.md'), 26 | 'utf8', 27 | ); 28 | 29 | // This makes sure we see the package description of the @backstage/types package 30 | // on the API reference index page. 31 | // Duplicate installations of @microsoft/api-extractor-model can cause this to 32 | // happen, but it also serves as a general check that the API reference is OK. 33 | if (!indexContent.includes('types used within Backstage')) { 34 | throw new Error( 35 | 'Could not find package documentation for @backstage/types in the API reference index. ' + 36 | 'Make sure there are no duplicate @microsoft or @rushstack dependencies.', 37 | ); 38 | } 39 | } 40 | 41 | main().catch(error => { 42 | console.error(error.stack); 43 | process.exit(1); 44 | }); 45 | -------------------------------------------------------------------------------- /plugins/time-saver-common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tduniec/backstage-plugin-time-saver-common", 3 | "description": "Common functionalities for the time-saver plugin", 4 | "version": "0.4.0", 5 | "main": "src/index.ts", 6 | "types": "src/index.ts", 7 | "license": "Apache-2.0", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/tduniec/backstage-timesaver-plugin.git" 11 | }, 12 | "author": "tduniec ", 13 | "publishConfig": { 14 | "access": "public", 15 | "main": "dist/index.cjs.js", 16 | "module": "dist/index.esm.js", 17 | "types": "dist/index.d.ts" 18 | }, 19 | "backstage": { 20 | "role": "common-library", 21 | "pluginId": "time-saver", 22 | "pluginPackages": [ 23 | "@tduniec/backstage-plugin-time-saver", 24 | "@tduniec/backstage-plugin-time-saver-backend", 25 | "@tduniec/backstage-plugin-time-saver-common" 26 | ] 27 | }, 28 | "sideEffects": false, 29 | "scripts": { 30 | "build": "backstage-cli package build", 31 | "lint": "backstage-cli package lint", 32 | "test": "backstage-cli package test", 33 | "clean": "backstage-cli package clean", 34 | "prepack": "backstage-cli package prepack", 35 | "postpack": "backstage-cli package postpack" 36 | }, 37 | "dependencies": { 38 | "@backstage/core-plugin-api": "^1.9.3", 39 | "@backstage/plugin-permission-common": "^0.8.0" 40 | }, 41 | "devDependencies": { 42 | "@backstage/cli": "^0.26.11" 43 | }, 44 | "files": [ 45 | "dist" 46 | ], 47 | "bugs": { 48 | "url": "https://github.com/tduniec/backstage-timesaver-plugin/issues" 49 | }, 50 | "homepage": "https://github.com/tduniec/backstage-timesaver-plugin#readme", 51 | "keywords": [ 52 | "backstage", 53 | "time-saver" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/migrations/test-executions.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // @ts-check 18 | 19 | /** 20 | * @param {import('knex').Knex} knex 21 | */ 22 | exports.up = async function up(knex) { 23 | let response = {}; 24 | 25 | await knex.schema 26 | .createTable('ts_excluded_tasks_everywhere', table => { 27 | table.comment('Table contains task ids to be excluded from calculations'); 28 | 29 | table 30 | .string('task_id') 31 | .primary() 32 | .notNullable() 33 | .comment('Template task ID'); 34 | }) 35 | .then( 36 | s => { 37 | response = { 38 | ...response, 39 | ts_excluded_tasks_everywhere: s, 40 | }; 41 | }, 42 | reason => { 43 | response = { 44 | ...response, 45 | ts_excluded_tasks_everywhere: `Not created: ${reason}`, 46 | }; 47 | console.log('Failed to create ts_excluded_tasks_everywhere.'); 48 | }, 49 | ); 50 | 51 | return response; 52 | }; 53 | 54 | /** 55 | * @param {import('knex').Knex} knex 56 | */ 57 | exports.down = async function down(knex) { 58 | return knex.schema.dropTable('ts_excluded_tasks_everywhere'); 59 | }; 60 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { DateTime, DateTimeMaybeValid } from 'luxon'; 17 | 18 | export function roundNumericValues(obj: T): T { 19 | const roundValue = (value: number): number => { 20 | const rounded = Math.round(value * 100) / 100; 21 | 22 | if (Number.isInteger(rounded)) { 23 | return rounded; 24 | } 25 | return parseFloat(rounded.toFixed(2)); 26 | }; 27 | 28 | const roundObject = (input: object | unknown): unknown => { 29 | if (typeof input === 'object' && input !== null) { 30 | Object.values(input).map((value: unknown) => { 31 | switch (typeof value) { 32 | case 'number': 33 | return roundValue(value); 34 | case 'object': 35 | return roundObject(value); 36 | default: 37 | return value; 38 | } 39 | }); 40 | } 41 | return input; 42 | }; 43 | 44 | return roundObject(obj) as T; 45 | } 46 | 47 | export function dateTimeFromIsoDate( 48 | isoDate: string | undefined = '', 49 | ): DateTimeMaybeValid { 50 | return DateTime.fromJSDate(new Date(isoDate)); 51 | } 52 | 53 | export function isoDateFromDateTime(dateTime: DateTime): string | null { 54 | return dateTime.toISO(); 55 | } 56 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { defineConfig } from '@playwright/test'; 18 | import { generateProjects } from '@backstage/e2e-test-utils/playwright'; 19 | 20 | /** 21 | * See https://playwright.dev/docs/test-configuration. 22 | */ 23 | export default defineConfig({ 24 | timeout: 60_000, 25 | 26 | expect: { 27 | timeout: 5_000, 28 | }, 29 | 30 | // Run your local dev server before starting the tests 31 | webServer: process.env.CI 32 | ? [] 33 | : [ 34 | { 35 | command: 'yarn dev', 36 | port: 3000, 37 | reuseExistingServer: true, 38 | timeout: 60_000, 39 | }, 40 | ], 41 | 42 | forbidOnly: !!process.env.CI, 43 | 44 | retries: process.env.CI ? 2 : 0, 45 | 46 | reporter: [['html', { open: 'never', outputFolder: 'e2e-test-report' }]], 47 | 48 | use: { 49 | actionTimeout: 0, 50 | baseURL: 51 | process.env.PLAYWRIGHT_URL ?? 52 | (process.env.CI ? 'http://localhost:7007' : 'http://localhost:3000'), 53 | screenshot: 'only-on-failure', 54 | trace: 'on-first-retry', 55 | }, 56 | 57 | outputDir: 'node_modules/.cache/e2e-test-results', 58 | 59 | projects: generateProjects(), // Find all packages with e2e-test folders 60 | }); 61 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/timeSaver/scheduler.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { 17 | AuthService, 18 | LoggerService, 19 | RootConfigService, 20 | } from '@backstage/backend-plugin-api'; 21 | import { TaskRunner } from '@backstage/backend-tasks'; 22 | import { TimeSaverHandler } from './handler'; 23 | import { TimeSaverStore } from '../database/TimeSaverDatabase'; 24 | 25 | export class TsScheduler { 26 | constructor( 27 | private readonly logger: LoggerService, 28 | private readonly config: RootConfigService, 29 | private readonly auth: AuthService, 30 | private readonly db: TimeSaverStore, 31 | ) {} 32 | 33 | async schedule(taskRunner: TaskRunner) { 34 | const tsHandler = new TimeSaverHandler( 35 | this.logger, 36 | this.config, 37 | this.auth, 38 | this.db, 39 | ); 40 | await taskRunner.run({ 41 | id: 'collect-templates-time-savings', 42 | fn: async () => { 43 | this.logger.info( 44 | 'START - Scheduler executed - fetching templates for TS plugin', 45 | ); 46 | await tsHandler.fetchTemplates(); 47 | this.logger.info( 48 | 'STOP - Scheduler executed - fetching templates for TS plugin', 49 | ); 50 | }, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish-npm: 10 | strategy: 11 | matrix: 12 | plugins: 13 | [ 14 | 'time-saver', 15 | 'time-saver-backend', 16 | 'time-saver-common', 17 | 'catalog-backend-module-time-saver-processor', 18 | ] 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Cache node modules 24 | uses: actions/cache@v4 25 | with: 26 | path: node_modules 27 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 28 | restore-keys: | 29 | ${{ runner.os }}-yarn- 30 | 31 | - uses: actions/setup-node@v3 32 | with: 33 | node-version: 18 34 | registry-url: https://registry.npmjs.org/ 35 | - name: Install dependencies 36 | run: yarn install 37 | 38 | - name: Run code quality checks 39 | run: | 40 | yarn tsc:full 41 | yarn lint:all 42 | yarn prettier:check 43 | 44 | - name: Run tests and build 45 | run: | 46 | yarn run clean 47 | yarn run build 48 | yarn run test 49 | working-directory: ./plugins/${{ matrix.plugins }} 50 | 51 | - name: Publish npm package 52 | run: | 53 | export PUBLISH=$(if [ "$(npm view $(cut -d "=" -f 2 <<< $(npm run env | grep "npm_package_name")) version)" == "$(cut -d "=" -f 2 <<< $(npm run env | grep "npm_package_version"))" ]; then echo false; else echo true; fi) 54 | if [ "$PUBLISH" == true ]; then npm publish ; else echo "since release not created not publishing the package"; fi 55 | working-directory: ./plugins/${{ matrix.plugins }} 56 | env: 57 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 58 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tduniec/backstage-plugin-catalog-backend-module-time-saver-processor", 3 | "description": "The time-saver-processor backend module for the catalog plugin.", 4 | "version": "1.1.0", 5 | "main": "src/index.ts", 6 | "types": "src/index.ts", 7 | "license": "Apache-2.0", 8 | "publishConfig": { 9 | "access": "public", 10 | "main": "dist/index.cjs.js", 11 | "types": "dist/index.d.ts" 12 | }, 13 | "backstage": { 14 | "role": "backend-plugin-module", 15 | "pluginId": "catalog", 16 | "pluginPackage": "@backstage/plugin-catalog-backend" 17 | }, 18 | "scripts": { 19 | "start": "backstage-cli package start", 20 | "build": "backstage-cli package build", 21 | "lint": "backstage-cli package lint", 22 | "test": "backstage-cli package test", 23 | "clean": "backstage-cli package clean", 24 | "prepack": "backstage-cli package prepack", 25 | "postpack": "backstage-cli package postpack" 26 | }, 27 | "dependencies": { 28 | "@backstage/backend-common": "^0.23.3", 29 | "@backstage/backend-plugin-api": "^0.7.0", 30 | "@backstage/catalog-model": "^1.5.0", 31 | "@backstage/plugin-catalog-node": "^1.12.4", 32 | "@backstage/types": "^1.1.1" 33 | }, 34 | "devDependencies": { 35 | "@backstage/backend-test-utils": "^0.4.4", 36 | "@backstage/cli": "^0.26.11" 37 | }, 38 | "files": [ 39 | "dist" 40 | ], 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/tduniec/backstage-timesaver-plugin.git" 44 | }, 45 | "keywords": [ 46 | "catalog-processor", 47 | "timesaver", 48 | "backstage" 49 | ], 50 | "author": "tduniec", 51 | "bugs": { 52 | "url": "https://github.com/tduniec/backstage-timesaver-plugin/issues" 53 | }, 54 | "homepage": "https://github.com/tduniec/backstage-timesaver-plugin#readme" 55 | } 56 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/migrations/role-column.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // @ts-check 18 | 19 | /** 20 | * @param {import('knex').Knex} knex 21 | */ 22 | exports.up = async function up(knex) { 23 | let response = {}; 24 | 25 | const noColumn = await knex.schema 26 | .hasColumn('ts_template_time_savings', 'role') 27 | .then(exists => !exists); 28 | 29 | if (noColumn) { 30 | await knex.schema 31 | .table('ts_template_time_savings', table => { 32 | table.string('role').comment('Developer`s role within the team'); 33 | }) 34 | .then( 35 | s => { 36 | response = { 37 | ...response, 38 | ts_template_time_savings: s, 39 | }; 40 | }, 41 | reason => { 42 | response = { 43 | ...response, 44 | ts_template_time_savings: `Column ROLE was not created: ${reason}`, 45 | }; 46 | console.log( 47 | 'Failed to create ROLE column in ts_template_time_savings.', 48 | ); 49 | }, 50 | ); 51 | } 52 | 53 | return response; 54 | }; 55 | 56 | /** 57 | * @param {import('knex').Knex} knex 58 | */ 59 | exports.down = async function down(knex) { 60 | return knex.schema.dropTable('ts_template_time_savings'); 61 | }; 62 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Gauge/TeamsGauge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import Gauge from './Gauge'; 20 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 21 | import { createUrlWithDates } from '../utils'; 22 | 23 | type GroupsResponse = { 24 | groups: string[]; 25 | }; 26 | 27 | export function TeamsGauge({ 28 | dates, 29 | }: { 30 | dates: IFilterDates; 31 | }): React.ReactElement { 32 | const configApi = useApi(configApiRef); 33 | const fetchApi = useApi(fetchApiRef); 34 | const [data, setData] = useState(null); 35 | 36 | useEffect(() => { 37 | const url = createUrlWithDates( 38 | `${configApi.getString('backend.baseUrl')}/api/time-saver/groups`, 39 | dates, 40 | ); 41 | 42 | fetchApi 43 | .fetch(url) 44 | .then(response => response.json()) 45 | .then(dt => setData(dt)) 46 | .catch(); 47 | }, [configApi, fetchApi, dates]); 48 | 49 | if (!data) { 50 | return ; 51 | } 52 | 53 | return ; 54 | } 55 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Gauge/TemplatesGauge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import Gauge from './Gauge'; 20 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 21 | import { createUrlWithDates } from '../utils'; 22 | 23 | type TemplateResponse = { 24 | templates: string[]; 25 | }; 26 | 27 | export function TemplatesGauge({ 28 | dates, 29 | }: { 30 | dates: IFilterDates; 31 | }): React.ReactElement { 32 | const configApi = useApi(configApiRef); 33 | const fetchApi = useApi(fetchApiRef); 34 | const [data, setData] = useState(null); 35 | 36 | useEffect(() => { 37 | const url = createUrlWithDates( 38 | `${configApi.getString('backend.baseUrl')}/api/time-saver/templates`, 39 | dates, 40 | ); 41 | 42 | fetchApi 43 | .fetch(url) 44 | .then(response => response.json()) 45 | .then(dt => setData(dt)) 46 | .catch(); 47 | }, [configApi, fetchApi, dates]); 48 | 49 | if (!data) { 50 | return ; 51 | } 52 | 53 | return ; 54 | } 55 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Gauge/Gauge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React from 'react'; 17 | import Avatar from '@material-ui/core/Avatar'; 18 | import { getRandomColor } from '../utils'; 19 | import { useTheme } from '@material-ui/core'; 20 | 21 | interface GaugeProps { 22 | number: number; 23 | heading?: string; 24 | } 25 | 26 | const Gauge: React.FC = ({ number, heading }) => { 27 | const theme = useTheme(); 28 | 29 | return ( 30 |
38 | 50 | {number} 51 | 52 |
59 | {heading} 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default Gauge; 66 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/README.md: -------------------------------------------------------------------------------- 1 | # Time Saver - catalog processor 2 | 3 | The Time Saver plugin provides an implementation of charts and statistics 4 | related to your time savings that are coming from usage of your templates. This 5 | catalog module translates granular Time Saver metadata stored on your template 6 | definitions into the more coarse `backstage.io/time-saved` metadata annotation, 7 | which may be leveraged by the Analytics API. 8 | 9 | ## Installation 10 | 11 | 1. Install the plugin package in your Backstage backend: 12 | 13 | ```sh 14 | # From your Backstage root directory 15 | yarn add --cwd packages/backend @tduniec/backstage-plugin-catalog-backend-module-time-saver-processor 16 | ``` 17 | 18 | 2. Wire up the processor in your backend. 19 | 20 | Add the `TimeSaverProcessor` to the catalog plugin in 21 | `packages/backend/src/catalog.ts`. 22 | 23 | ```diff 24 | + import { 25 | + TimeSaverProcessor, 26 | + } from '@tduniec/backstage-plugin-catalog-backend-module-time-saver-processor'; 27 | import { Router } from 'express'; 28 | import { PluginEnvironment } from '../types'; 29 | 30 | export default async function createPlugin( 31 | env: PluginEnvironment, 32 | ): Promise { 33 | const builder = CatalogBuilder.create(env); 34 | + builder.addProcessor(new TimeSaverProcessor(logger)); 35 | // ... 36 | return router; 37 | } 38 | ``` 39 | 40 | **New Backend System** 41 | 42 | If you are using the New Backend System, you can instead do so by updating 43 | your `packages/backend/src/index.ts` in the following way: 44 | 45 | ```diff 46 | import { createBackend } from '@backstage/backend-defaults'; 47 | 48 | const backend = createBackend(); 49 | backend.add(import('@backstage/plugin-app-backend/alpha')); 50 | + backend.add(import('@tduniec/backstage-plugin-catalog-backend-module-time-saver-processor')); 51 | // ... 52 | backend.start(); 53 | ``` 54 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Gauge/TemplatesTaskCountGauge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import Gauge from './Gauge'; 20 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 21 | import { createUrlWithDates } from '../utils'; 22 | 23 | type TemplateTaskCountResponse = { 24 | templateCount: number; 25 | dates: IFilterDates; 26 | }; 27 | 28 | export function TemplateCountGauge({ 29 | dates, 30 | }: { 31 | dates: IFilterDates; 32 | }): React.ReactElement { 33 | const configApi = useApi(configApiRef); 34 | const fetchApi = useApi(fetchApiRef); 35 | const [data, setData] = useState(null); 36 | 37 | useEffect(() => { 38 | const url = createUrlWithDates( 39 | `${configApi.getString( 40 | 'backend.baseUrl', 41 | )}/api/time-saver/getTemplateCount`, 42 | dates, 43 | ); 44 | 45 | fetchApi 46 | .fetch(url) 47 | .then(response => response.json()) 48 | .then(dt => setData(dt)) 49 | .catch(); 50 | }, [configApi, fetchApi, dates]); 51 | 52 | if (!data) { 53 | return ; 54 | } 55 | 56 | return ; 57 | } 58 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/database/types.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | export type TemplateTimeSavings = { 4 | id?: string; 5 | team: string; 6 | role: string; 7 | createdAt: DateTime; 8 | createdBy: string; 9 | timeSaved: number; 10 | templateName: string; 11 | templateTaskId: string; 12 | templateTaskStatus: string; 13 | }; 14 | 15 | export type TemplateTimeSavingsCollection = TemplateTimeSavings[]; 16 | 17 | export type TemplateTimeSavingsDbRow = { 18 | id?: string; 19 | team: string; 20 | role: string; 21 | created_at: string; 22 | created_by: string; 23 | time_saved: number; 24 | template_name: string; 25 | template_task_id: string; 26 | template_task_status: string; 27 | }; 28 | 29 | export type TemplateTimeSavingsDistinctRbRow = { 30 | id?: string; 31 | team?: string; 32 | role?: string; 33 | created_at?: string; 34 | created_by?: string; 35 | time_saved?: number; 36 | template_name?: string; 37 | template_task_id?: string; 38 | template_task_status?: string; 39 | }; 40 | 41 | export type TimeSavedStatisticsDbRow = { 42 | team?: string; 43 | template_name?: string; 44 | time_saved?: string | undefined; 45 | }; 46 | 47 | export type TimeSavedStatistics = { 48 | team?: string; 49 | templateName?: string; 50 | timeSaved: number; 51 | }; 52 | 53 | export type GroupSavingsDivisionDbRow = { 54 | team: string; 55 | total_time_saved: number; 56 | percentage: number; 57 | }; 58 | 59 | export type GroupSavingsDivision = { 60 | team: string; 61 | percentage: number; 62 | }; 63 | 64 | export type RawDbTimeSummary = { 65 | team?: string; 66 | template_name?: string; 67 | date?: string; 68 | total_time_saved?: number; 69 | }; 70 | 71 | export type TimeSummary = { 72 | team?: string; 73 | templateName?: string; 74 | date: DateTime; 75 | totalTimeSaved: number; 76 | }; 77 | 78 | export type TemplateCountDbRow = { 79 | count: number; 80 | }; 81 | 82 | export type TotalTimeSavedDbRow = { 83 | sum: number; 84 | }; 85 | 86 | export type IQuery = { 87 | start?: string; 88 | end?: string; 89 | }; 90 | -------------------------------------------------------------------------------- /app-config.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | title: Backstage TimeSaver Plugin 3 | baseUrl: http://localhost:3000 4 | 5 | backend: 6 | baseUrl: http://localhost:7007 7 | listen: 8 | port: 7007 9 | database: 10 | client: better-sqlite3 11 | connection: ':memory:' 12 | cache: 13 | store: memory 14 | cors: 15 | origin: http://localhost:3000 16 | methods: [GET, HEAD, PATCH, POST, PUT, DELETE] 17 | credentials: true 18 | csp: 19 | connect-src: ["'self'", 'http:', 'https:'] 20 | # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference 21 | # Default Helmet Content-Security-Policy values can be removed by setting the key to false 22 | reading: 23 | allow: 24 | - host: localhost 25 | 26 | ts: 27 | frontend: 28 | table: 29 | showInDays: true # if true, the table shows days [boolean] 30 | hoursPerDay: 8 # how many hours count as a day [number] 31 | scheduler: 32 | parallelProcessing: 50 # optional -> number of tasks to process one thread -> CPU impact: Default to 100 33 | handler: 34 | frequency: 'PT1H' # Frequency in ISO 8601 duration format 35 | timeout: 'PT5M' # Timeout in ISO 8601 duration format 36 | initialDelay: 'PT1M' # Initial delay in ISO 8601 duration format 37 | backward: 38 | config: | 39 | [ 40 | { 41 | "entityRef": "template:default/cloudevops-create-terraform-repo", 42 | "engineering": { 43 | "devops": 2, 44 | "development_team": 3, 45 | "compliance": 0.5 46 | } 47 | }, 48 | { 49 | "entityRef": "template:default/add-iam-role-to-iam-repository", 50 | "engineering": { 51 | "devops": 1, 52 | "security": 2, 53 | "development_team": 2 54 | } 55 | }, 56 | { 57 | "entityRef": "template:default/cloudevops-create-eks-resources-for-project", 58 | "engineering": { 59 | "devops": 3, 60 | "security": 1, 61 | "development_team": 3 62 | } 63 | } 64 | ] 65 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/src/processor/TimeSaverProcessor.ts: -------------------------------------------------------------------------------- 1 | import { LoggerService } from '@backstage/backend-plugin-api'; 2 | import { Entity } from '@backstage/catalog-model'; 3 | import { CatalogProcessor } from '@backstage/plugin-catalog-node'; 4 | import { JsonValue } from '@backstage/types'; 5 | 6 | type Substitute = { 7 | engineering: Record; 8 | }; 9 | 10 | function isValidSubstitute( 11 | substitute: JsonValue | undefined, 12 | ): substitute is Substitute { 13 | return ( 14 | typeof substitute === 'object' && 15 | !Array.isArray(substitute) && 16 | substitute !== null && 17 | substitute !== undefined && 18 | substitute.engineering !== undefined 19 | ); 20 | } 21 | 22 | export class TimeSaverProcessor implements CatalogProcessor { 23 | #TimeSaved = 'backstage.io/time-saved'; 24 | 25 | constructor(private readonly logger: LoggerService) {} 26 | 27 | getProcessorName() { 28 | return 'TimeSaverProcessor'; 29 | } 30 | 31 | async preProcessEntity(entity: Entity) { 32 | // Ignore non-templates 33 | if (entity.kind !== 'Template') { 34 | return entity; 35 | } 36 | 37 | // Ignore any templates that already have backstage.io/time-saved metadata 38 | if (entity.metadata.annotations?.[this.#TimeSaved]) { 39 | return entity; 40 | } 41 | 42 | // Ignore templates that do not have substitute timing defined. 43 | const { substitute } = entity.metadata; 44 | if (!isValidSubstitute(substitute)) { 45 | return entity; 46 | } 47 | 48 | // Calculate hours saved and set on standard annotation. 49 | const hoursSaved = Object.values(substitute.engineering).reduce( 50 | (total, hours) => total + hours, 51 | 0, 52 | ); 53 | if (hoursSaved > 0) { 54 | entity.metadata.annotations = { 55 | ...entity.metadata.annotations, 56 | [this.#TimeSaved]: `PT${hoursSaved}H`, 57 | }; 58 | } 59 | this.logger.debug( 60 | `Template Entity ${entity.metadata.name} has time-saved annotation ${hoursSaved}`, 61 | ); 62 | 63 | return entity; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Gauge/TimeSavedGauge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import Gauge from './Gauge'; 20 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 21 | import { createUrlWithDates } from '../utils'; 22 | 23 | type TimeSavedResponse = { 24 | timeSaved: number; 25 | }; 26 | 27 | interface TimeSavedGaugeProps { 28 | number?: number; 29 | heading: string; 30 | dates: IFilterDates; 31 | } 32 | 33 | export function TimeSavedGauge({ 34 | number, 35 | heading, 36 | dates, 37 | }: TimeSavedGaugeProps): React.ReactElement { 38 | const configApi = useApi(configApiRef); 39 | const fetchApi = useApi(fetchApiRef); 40 | const [data, setData] = useState(null); 41 | 42 | useEffect(() => { 43 | let url = `${configApi.getString( 44 | 'backend.baseUrl', 45 | )}/api/time-saver/getTimeSavedSum`; 46 | if (number) { 47 | url = `${url}?divider=${number}`; 48 | } 49 | 50 | url = createUrlWithDates(url, dates); 51 | 52 | fetchApi 53 | .fetch(url) 54 | .then(response => response.json()) 55 | .then(dt => setData(dt)) 56 | .catch(); 57 | }, [configApi, number, fetchApi, dates]); 58 | 59 | if (!data) { 60 | return ; 61 | } 62 | const roundedData = Math.round(data.timeSaved); 63 | 64 | return ; 65 | } 66 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Gauge/EmptyDbContent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import { 20 | Table, 21 | Paper, 22 | TableBody, 23 | TableCell, 24 | TableContainer, 25 | TableRow, 26 | } from '@material-ui/core'; 27 | 28 | type TemplatesResponse = { 29 | templates: string[]; 30 | }; 31 | 32 | export function EmptyTimeSaver(): React.ReactElement { 33 | const configApi = useApi(configApiRef); 34 | const fetchApi = useApi(fetchApiRef); 35 | 36 | const [data, setData] = useState(null); 37 | 38 | useEffect(() => { 39 | const url = `${configApi.getString( 40 | 'backend.baseUrl', 41 | )}/api/time-saver/templates`; 42 | 43 | fetchApi 44 | .fetch(url) 45 | .then(response => response.json()) 46 | .then(dt => setData(dt)) 47 | .catch(); 48 | }, [configApi, fetchApi]); 49 | 50 | if (!data) { 51 | return ; 52 | } 53 | const cellStyle: React.CSSProperties = { 54 | color: 'red', 55 | fontWeight: 'bold', 56 | fontSize: '20px', 57 | }; 58 | 59 | return data && data.templates.length === 0 ? ( 60 | 61 | 62 | 63 | 64 | 65 | Please fill your templates with data, they will be displayed after 66 | their executions 67 | 68 | 69 | 70 |
71 |
72 | ) : ( 73 | <> 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /plugins/time-saver-common/README.md: -------------------------------------------------------------------------------- 1 | # Time Saver - common 2 | 3 | This plugin provides an implementation of charts and statistics related to your time savings that are coming from usage of your templates. Plugins is built from frontend and backend part. Backend plugin is responsible for scheduled stats parsing process and data storage. 4 | 5 | ## Dependencies 6 | 7 | - [time-saver](https://github.com/tduniec/backstage-timesaver-plugin/tree/main/plugins/time-saver) 8 | - [time-saver-backend](https://github.com/tduniec/backstage-timesaver-plugin/tree/main/plugins/time-saver-backend) 9 | 10 | ## Code 11 | 12 | https://github.com/tduniec/backstage-timesaver-plugin.git 13 | 14 | ## Installation 15 | 16 | 1. Install the plugin package in your Backstage app: 17 | 18 | ```sh 19 | # From your Backstage root directory 20 | yarn add --cwd packages/backend @tduniec/backstage-plugin-time-saver-common 21 | ``` 22 | 23 | or 24 | 25 | ```sh 26 | # From your Backstage root directory 27 | yarn add --cwd packages/app @tduniec/backstage-plugin-time-saver-common 28 | ``` 29 | 30 | 2. Wire up the API implementation to your `packages/app/src/App.tsx`: 31 | 32 | ```tsx 33 | import { timeSaverPermission } from '@tduniec/backstage-plugin-time-saver-common'; 34 | 35 | ... 36 | 37 | 41 | 42 | 43 | } 44 | /> 45 | 46 | ``` 47 | 48 | 2. Wire up in the navigation pane the in `packages/app/src/component/Root/Root.tsx`: 49 | 50 | ```tsx 51 | 52 | import { timeSaverPermission } from '@tduniec/backstage-plugin-time-saver-common'; 53 | 54 | ... 55 | 56 | } 59 | > 60 | 65 | 66 | ``` 67 | 68 | 3. Wire up in the permissions backend in `packages/backend/src/plugins/permission.ts`: 69 | 70 | ```ts 71 | ... 72 | import { timeSaverPermission } from '@tduniec/backstage-plugin-time-saver-common'; 73 | ... 74 | 75 | if (isPermission(request.permission, timeSaverPermission)) { 76 | if (isAdmin) { //example condition 77 | return { 78 | result: AuthorizeResult.ALLOW, 79 | }; 80 | } 81 | return { 82 | result: AuthorizeResult.DENY, 83 | }; 84 | } 85 | 86 | ``` 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time Saver 2 | 3 | This plugin provides an implementation of charts and statistics related to your time savings that are coming from usage of your templates. Plugins is built from frontend and backend part. This part of plugin `frontend` is responsible of providing views with charts describing data collected from `backend` part of plugin. 4 | 5 | ## Repos 6 | 7 | - [time-saver](./plugins/time-saver) 8 | - [time-saver-backend](./plugins/time-saver-backend) 9 | - [time-saver-common](./plugins/time-saver-common) 10 | - [time-saver-processor](./plugins/catalog-backend-module-time-saver-processor) 11 | 12 | ## Screens 13 | 14 | ![Screenshot of the AllStats plugin Charts](./plugins/time-saver/docs/tsAllStats.png) 15 | ![Screenshot of the AllStats2 plugin Charts](./plugins/time-saver/docs/tsAllStats2.png) 16 | ![Screenshot of the ByTeam plugin Charts](./plugins/time-saver/docs/tsByTeam.png) 17 | ![Screenshot of the ByTemplate plugin Charts](./plugins/time-saver/docs/tsByTemplate.png) 18 | 19 | ## Installation 20 | 21 | Please follow the instructions in each plugin README.md 22 | 23 | ## Generate Statistics 24 | 25 | Configure your template definition like described below: 26 | Provide an object under `metadata`. Provide quantities of saved time by each group executing one template in **_hours_** preferably 27 | 28 | ```diff 29 | apiVersion: scaffolder.backstage.io/v1beta3 30 | kind: Template 31 | metadata: 32 | name: example-template 33 | title: create-github-project 34 | description: Creates Github project 35 | + substitute: 36 | + engineering: 37 | + devops: 1 38 | + security: 4 39 | + development_team: 2 40 | spec: 41 | owner: group:default/backstage-admins 42 | type: service 43 | ``` 44 | 45 | Scheduler is running with its default setup every **5 minutes** to generate data from executed templates with these information. 46 | 47 | ## Migration 48 | 49 | This plugins supports backward compatibility with migration. You can specify your Time Saver metadata for each template name. Then the migration will be performed once executing the API request to `/migrate` endpoint of the plugin. 50 | 51 | Configure your backward time savings here: 52 | 53 | Open the `app-config.yaml` file 54 | 55 | ```yaml 56 | ts: 57 | backward: 58 | config: | 59 | [ 60 | { 61 | "entityRef": "template:default/create-github-project", 62 | "engineering": { 63 | "devops": 8, 64 | "development_team": 8, 65 | "security": 3 66 | } 67 | } 68 | ] 69 | # extend this list if needed 70 | ``` 71 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @tduniec/backstage-plugin-time-saver-backend 2 | 3 | ## 4.1.2 4 | 5 | ### Patch Changes 6 | 7 | - Memory and CPU optimizations in tsHandler.ts 8 | 9 | ## 4.1.1 10 | 11 | ### Patch Changes 12 | 13 | - tsScheduler task name change - fixing concurent executions of ts collection 14 | 15 | ## 4.1.0 16 | 17 | ### Minor Changes 18 | 19 | - Adding optional configuration for scheduler that can be passed to `tsHandler`. 20 | - Adding global scope for scheduler that should help in attempt to ensure that only one worker machine runs the task at a time 21 | 22 | ## 4.0.0 23 | 24 | ### Major Changes 25 | 26 | - Implemented filtering by `start` and `end` properties for the `created_at` column in the Timesaver API and DB client (`createBuilderWhereDates` private method). Example request: `/api/time-saver/groups?start=2024-07-01&end=2024-10-11` 27 | 28 | - changes provided by [@stanislavec](https://github.com/stanislavec) 29 | 30 | ## 3.1.0 31 | 32 | ### Minor Changes 33 | 34 | - Added option to exclude some of the templates in "ts_exclude_tasks_everywhere" for the handler to filterout the excluded changes from calulations 35 | - fixes the components 36 | - changes provided by @stanislavec 37 | 38 | ## 3.0.0 39 | 40 | ### Major Changes 41 | 42 | - Upgraded time saver DB client to use knex functions to build queries. Previously, raw queries were used that were only compatible with PostgreSQL. Users are now able to use other databases and even deploy locally using sqlite. 43 | 44 | ### Minor Changes 45 | 46 | - Replaced native date functions for luxon. 47 | 48 | ## 2.4.0 49 | 50 | ### Minor Changes 51 | 52 | - Provided dependenceis upgrade to match Backstage 1.29 version 53 | 54 | ## 2.3.0 55 | 56 | ### Minor Changes 57 | 58 | - Implemented yarn 3.x 59 | 60 | ## 2.2.1 61 | 62 | ### Patch Changes 63 | 64 | - Fixed DB lock when using plugins DB schemas. 65 | - Fixed use of deprecated Backstage database handlers. 66 | 67 | ## 2.2.0 68 | 69 | ### Minor Changes 70 | 71 | - Added /generate-sample-classification API to provide easier backward migrations. 72 | 73 | ## 2.1.0 74 | 75 | ### Minor Changes 76 | 77 | - Fixed scaffolder DB corruption when trying to backward migrate. Opened up /migrate endpoint unauthenticated. Improved DB querying through Knex. 78 | 79 | ## 2.0.0 80 | 81 | ### Major Changes 82 | 83 | - Replaced Winston Logger with the new backend LoggerService 84 | - Integrated new backend Auth service 85 | 86 | ### Minor Changes 87 | 88 | - Decreased the initial delay time to fetch templates to 30 seconds. 89 | - Removed the need for a static external token 90 | 91 | ## 1.1.0 92 | 93 | ### Minor Changes 94 | 95 | - ec4abcc: Added changelog 96 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TemplateAutocompleteComponent/TemplateAutocompleteComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as React from 'react'; 17 | // TODO :: Fix the need to place this exception: 18 | // eslint-disable-next-line no-restricted-imports 19 | import { Autocomplete } from '@mui/material'; 20 | import { useEffect, useState } from 'react'; 21 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 22 | import CircularProgress from '@material-ui/core/CircularProgress'; 23 | import { TextField } from '@material-ui/core'; 24 | 25 | interface TemplateChange { 26 | onTemplateChange: (templateTask: string) => void; 27 | } 28 | 29 | type TemplateResponse = { 30 | templates: string[]; 31 | }; 32 | 33 | export default function TemplateAutocomplete({ 34 | onTemplateChange, 35 | }: TemplateChange) { 36 | const [_task, setTask] = React.useState(''); 37 | 38 | const handleChange = ( 39 | _event: React.ChangeEvent>, 40 | value: string | null, 41 | ) => { 42 | const selectedTemplateTaskId = value || ''; 43 | setTask(selectedTemplateTaskId); 44 | onTemplateChange(selectedTemplateTaskId); 45 | }; 46 | 47 | const [data, setData] = useState(null); 48 | const configApi = useApi(configApiRef); 49 | const fetchApi = useApi(fetchApiRef); 50 | useEffect(() => { 51 | fetchApi 52 | .fetch( 53 | `${configApi.getString('backend.baseUrl')}/api/time-saver/templates`, 54 | ) 55 | .then(response => response.json()) 56 | .then(dt => setData(dt)) 57 | .catch(); 58 | }, [configApi, fetchApi]); 59 | 60 | if (!data) { 61 | return ; 62 | } 63 | 64 | const templates = data.templates; 65 | 66 | return ( 67 | ( 73 | 74 | )} 75 | /> 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/migrations/init.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // @ts-check 18 | 19 | /** 20 | * @param {import('knex').Knex} knex 21 | */ 22 | exports.up = async function up(knex) { 23 | let response = {}; 24 | 25 | await knex.schema 26 | .createTable('ts_template_time_savings', table => { 27 | table.comment( 28 | 'Table contains template time savings with relation to the templateTaskId', 29 | ); 30 | table 31 | .uuid('id') 32 | .primary() 33 | .notNullable() 34 | .defaultTo(knex.fn.uuid()) 35 | .comment('UUID'); 36 | 37 | table 38 | .timestamp('created_at', { useTz: false, precision: 0 }) 39 | .notNullable() 40 | .defaultTo(knex.fn.now()) 41 | .comment('The creation time of the record'); 42 | 43 | table.string('template_task_id').comment('Template task ID'); 44 | 45 | table 46 | .string('template_name') 47 | .comment('Template name as template entity_reference'); 48 | 49 | table.string('team').comment('Team name of saved time'); 50 | 51 | table 52 | .float('time_saved', 2) 53 | .comment('time saved by the team within template task ID, in hours'); 54 | 55 | table.string('template_task_status').comment('template task status'); 56 | 57 | table 58 | .string('created_by') 59 | .comment('entity reference to the user that has executed the template'); 60 | }) 61 | .then( 62 | s => { 63 | response = { 64 | ...response, 65 | ts_template_time_savings: s, 66 | }; 67 | }, 68 | reason => { 69 | response = { 70 | ...response, 71 | ts_template_time_savings: `Not created: ${reason}`, 72 | }; 73 | console.log('Failed to create table ts_template_time_savings.'); 74 | }, 75 | ); 76 | 77 | return response; 78 | }; 79 | 80 | /** 81 | * @param {import('knex').Knex} knex 82 | */ 83 | exports.down = async function down(knex) { 84 | return knex.schema.dropTable('ts_template_time_savings'); 85 | }; 86 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TemplateTaskAutocompleteComponent/TemplateTaskAutocompleteComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as React from 'react'; 17 | // TODO :: Fix the need to place this exception: 18 | // eslint-disable-next-line no-restricted-imports 19 | import { Autocomplete } from '@mui/material'; 20 | import { useEffect, useState } from 'react'; 21 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 22 | import CircularProgress from '@material-ui/core/CircularProgress'; 23 | import { TextField } from '@material-ui/core'; 24 | 25 | interface TemplateTaskChange { 26 | onTemplateTaskChange: (templateTask: string) => void; 27 | } 28 | 29 | type TemplateTasksResponse = { 30 | templateTasks: string[]; 31 | }; 32 | 33 | export default function TemplateTaskAutocomplete({ 34 | onTemplateTaskChange, 35 | }: TemplateTaskChange) { 36 | const [_task, setTask] = React.useState(''); 37 | 38 | const handleChange = ( 39 | _event: React.ChangeEvent>, 40 | value: string | null, 41 | ) => { 42 | const selectedTemplateTaskId = value || ''; 43 | setTask(selectedTemplateTaskId); 44 | onTemplateTaskChange(selectedTemplateTaskId); 45 | }; 46 | 47 | const [data, setData] = useState(null); 48 | const configApi = useApi(configApiRef); 49 | const fetchApi = useApi(fetchApiRef); 50 | 51 | useEffect(() => { 52 | fetchApi 53 | .fetch( 54 | `${configApi.getString( 55 | 'backend.baseUrl', 56 | )}/api/time-saver/templateTasks`, 57 | ) 58 | .then(response => response.json()) 59 | .then(dt => setData(dt)) 60 | .catch(); 61 | }, [configApi, fetchApi]); 62 | 63 | if (!data) { 64 | return ; 65 | } 66 | 67 | const templates = data.templateTasks; 68 | 69 | return ( 70 | ( 77 | 78 | )} 79 | /> 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tduniec/backstage-plugin-time-saver-backend", 3 | "version": "4.1.2", 4 | "main": "src/index.ts", 5 | "types": "src/index.ts", 6 | "license": "Apache-2.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/tduniec/backstage-timesaver-plugin.git" 10 | }, 11 | "author": "tduniec ", 12 | "publishConfig": { 13 | "access": "public", 14 | "main": "dist/index.cjs.js", 15 | "types": "dist/index.d.ts" 16 | }, 17 | "backstage": { 18 | "role": "backend-plugin", 19 | "pluginId": "time-saver", 20 | "pluginPackages": [ 21 | "@tduniec/backstage-plugin-time-saver", 22 | "@tduniec/backstage-plugin-time-saver-backend", 23 | "@tduniec/backstage-plugin-time-saver-common" 24 | ] 25 | }, 26 | "scripts": { 27 | "start": "backstage-cli package start", 28 | "build": "backstage-cli package build", 29 | "lint": "backstage-cli package lint", 30 | "test": "backstage-cli package test", 31 | "clean": "backstage-cli package clean", 32 | "prepack": "backstage-cli package prepack", 33 | "postpack": "backstage-cli package postpack" 34 | }, 35 | "dependencies": { 36 | "@backstage/backend-common": "^0.23.3", 37 | "@backstage/backend-defaults": "^0.4.1", 38 | "@backstage/backend-plugin-api": "^0.7.0", 39 | "@backstage/backend-tasks": "^0.5.27", 40 | "@backstage/config": "^1.2.0", 41 | "@backstage/plugin-permission-common": "^0.8.0", 42 | "@mui/system": "^5.2.3", 43 | "@types/express": "*", 44 | "base64-js": "^1.5.1", 45 | "express": "^4.17.1", 46 | "express-promise-router": "^4.1.0", 47 | "jsonwebtoken": "^9.0.2", 48 | "knex": "^3.1.0", 49 | "luxon": "^3.5.0", 50 | "node-fetch": "^2.6.7", 51 | "uuid": "^9.0.1", 52 | "yn": "^4.0.0" 53 | }, 54 | "devDependencies": { 55 | "@backstage/backend-test-utils": "^0.4.4", 56 | "@backstage/cli": "^0.26.11", 57 | "@backstage/test-utils": "^1.5.9", 58 | "@testing-library/dom": "^9.0.0", 59 | "@testing-library/react": "^14.0.0", 60 | "@testing-library/user-event": "^14.0.0", 61 | "@types/luxon": "^3.4.2", 62 | "@types/supertest": "^2.0.12", 63 | "@types/uuid": "^9.0.8", 64 | "msw": "^1.0.0", 65 | "supertest": "^6.2.4" 66 | }, 67 | "files": [ 68 | "dist", 69 | "migrations/*.{js,d.ts}" 70 | ], 71 | "description": "This plugin provides an implementation of charts and statistics related to your time savings that are coming from usage of your templates. Plugins is built from frontend and backend part. Backend plugin is responsible for scheduled stats parsing process and data storage.", 72 | "bugs": { 73 | "url": "https://github.com/tduniec/backstage-timesaver-plugin/issues" 74 | }, 75 | "homepage": "https://github.com/tduniec/backstage-timesaver-plugin#readme", 76 | "keywords": [ 77 | "backstage", 78 | "time-saver" 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /scripts/create-release-tag.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @backstage/no-undeclared-imports */ 3 | /* 4 | * Copyright 2020 The Backstage Authors 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | const { Octokit } = require('@octokit/rest'); 20 | const path = require('path'); 21 | const fs = require('fs-extra'); 22 | const { EOL } = require('os'); 23 | 24 | const baseOptions = { 25 | owner: 'backstage', 26 | repo: 'backstage', 27 | }; 28 | 29 | async function getCurrentReleaseTag() { 30 | const rootPath = path.resolve(__dirname, '../package.json'); 31 | return fs.readJson(rootPath).then(_ => _.version); 32 | } 33 | 34 | async function createGitTag(octokit, commitSha, tagName) { 35 | const annotatedTag = await octokit.git.createTag({ 36 | ...baseOptions, 37 | tag: tagName, 38 | message: tagName, 39 | object: commitSha, 40 | type: 'commit', 41 | }); 42 | 43 | try { 44 | await octokit.git.createRef({ 45 | ...baseOptions, 46 | ref: `refs/tags/${tagName}`, 47 | sha: annotatedTag.data.sha, 48 | }); 49 | } catch (ex) { 50 | if ( 51 | ex.status === 422 && 52 | ex.response.data.message === 'Reference already exists' 53 | ) { 54 | throw new Error(`Tag ${tagName} already exists in repository`); 55 | } 56 | console.error(`Tag creation for ${tagName} failed`); 57 | throw ex; 58 | } 59 | } 60 | 61 | async function main() { 62 | if (!process.env.GITHUB_SHA) { 63 | throw new Error('GITHUB_SHA is not set'); 64 | } 65 | if (!process.env.GITHUB_TOKEN) { 66 | throw new Error('GITHUB_TOKEN is not set'); 67 | } 68 | if (!process.env.GITHUB_OUTPUT) { 69 | throw new Error('GITHUB_OUTPUT environment variable not set'); 70 | } 71 | 72 | const commitSha = process.env.GITHUB_SHA; 73 | const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); 74 | 75 | const releaseVersion = await getCurrentReleaseTag(); 76 | const tagName = `v${releaseVersion}`; 77 | 78 | console.log(`Creating release tag ${tagName} at ${commitSha}`); 79 | await createGitTag(octokit, commitSha, tagName); 80 | 81 | await fs.appendFile(process.env.GITHUB_OUTPUT, `tag_name=${tagName}${EOL}`); 82 | await fs.appendFile( 83 | process.env.GITHUB_OUTPUT, 84 | `version=${releaseVersion}${EOL}`, 85 | ); 86 | } 87 | 88 | main().catch(error => { 89 | console.error(error.stack); 90 | process.exit(1); 91 | }); 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tduniec/backstage-timesaver-plugin", 3 | "version": "2.0.0", 4 | "description": "This plugin provides an implementation of charts and statistics related to your time savings that are coming from usage of your templates.", 5 | "private": true, 6 | "engines": { 7 | "node": "18 || 20" 8 | }, 9 | "workspaces": { 10 | "packages": [ 11 | "plugins/*" 12 | ] 13 | }, 14 | "repository": "https://github.com/tduniec/backstage-timesaver-plugin.git", 15 | "author": "tduniec ", 16 | "license": "Apache-2.0", 17 | "scripts": { 18 | "dev": "concurrently \"yarn start\" \"yarn start-backend\"", 19 | "start": "yarn workspace app start", 20 | "start-backend": "yarn workspace backend start", 21 | "build:backend": "yarn workspace backend build", 22 | "build:all": "backstage-cli repo build --all", 23 | "build-image": "yarn workspace backend build-image", 24 | "tsc": "tsc", 25 | "tsc:full": "tsc --skipLibCheck false --incremental false", 26 | "clean": "backstage-cli repo clean", 27 | "test": "backstage-cli repo test", 28 | "test:all": "backstage-cli repo test --coverage", 29 | "test:e2e": "playwright test", 30 | "fix": "backstage-cli repo fix", 31 | "lint": "backstage-cli repo lint --since origin/main", 32 | "lint:all": "backstage-cli repo lint", 33 | "prettier:check": "prettier --check .", 34 | "prettier:fix": "prettier --write .", 35 | "new": "backstage-cli new --scope internal" 36 | }, 37 | "resolutions": { 38 | "@types/react": "^18", 39 | "@types/react-dom": "^18" 40 | }, 41 | "devDependencies": { 42 | "@backstage/cli": "^0.26.11", 43 | "@backstage/e2e-test-utils": "^0.1.1", 44 | "@changesets/cli": "^2.27.7", 45 | "@mui/base": "^5.0.0-beta.40", 46 | "@playwright/test": "^1.32.3", 47 | "@spotify/prettier-config": "^12.0.0", 48 | "concurrently": "^8.0.0", 49 | "lerna": "^7.3.0", 50 | "node-gyp": "^10.0.0", 51 | "prettier": "^2.3.2", 52 | "react-router-dom": "^6.25.1", 53 | "typescript": "~5.4.0" 54 | }, 55 | "prettier": "@spotify/prettier-config", 56 | "lint-staged": { 57 | "*.{js,jsx,ts,tsx,mjs,cjs}": [ 58 | "eslint --fix", 59 | "prettier --write" 60 | ], 61 | "*.{json,md}": [ 62 | "prettier --write" 63 | ], 64 | "*.md": [ 65 | "node ./scripts/check-docs-quality" 66 | ], 67 | "{plugins,packages}/*/catalog-info.yaml": [ 68 | "yarn backstage-repo-tools generate-catalog-info --ci" 69 | ], 70 | "{.github/CODEOWNERS,package.json}": [ 71 | "yarn backstage-repo-tools generate-catalog-info", 72 | "git add */catalog-info.yaml" 73 | ], 74 | "./yarn.lock": [ 75 | "node ./scripts/verify-lockfile-duplicates --fix" 76 | ], 77 | "*/yarn.lock": [ 78 | "node ./scripts/verify-lockfile-duplicates --fix" 79 | ] 80 | }, 81 | "files": [ 82 | "plugins/time-saver-backend//migrations/*.{js,d.ts}" 83 | ], 84 | "packageManager": "yarn@3.8.3" 85 | } 86 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/database/ScaffolderDatabase.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { DatabaseManager } from '@backstage/backend-defaults/database'; 17 | import { 18 | LoggerService, 19 | RootConfigService, 20 | } from '@backstage/backend-plugin-api'; 21 | import { Knex } from 'knex'; 22 | 23 | export interface ScaffolderStore { 24 | collectSpecByTemplateId(templateTaskId: string): Promise; 25 | updateTemplateTaskById( 26 | templateTaskId: string, 27 | templateTaskSpecs: string, 28 | ): Promise; 29 | } 30 | 31 | export class ScaffolderDatabase implements ScaffolderStore { 32 | constructor( 33 | private readonly knex: Knex, 34 | private readonly logger: LoggerService, 35 | ) {} 36 | static async create(config: RootConfigService, logger: LoggerService) { 37 | // const knex = await database.getClient(); 38 | 39 | const db = DatabaseManager.fromConfig(config).forPlugin('scaffolder'); 40 | const knex = await db.getClient(); 41 | 42 | return new ScaffolderDatabase(knex, logger); 43 | } 44 | async collectSpecByTemplateId( 45 | templateTaskId: string, 46 | ): Promise { 47 | try { 48 | const result = await this.knex('tasks') 49 | .select('spec') 50 | .where('id', templateTaskId); 51 | this.logger.debug( 52 | `collectSpecByTemplateId : Data selected successfully ${JSON.stringify( 53 | result, 54 | )}`, 55 | ); 56 | return result; 57 | } catch (error) { 58 | this.logger.error( 59 | 'Error selecting data:', 60 | error ? (error as Error) : undefined, 61 | ); 62 | throw error; 63 | } 64 | } 65 | async updateTemplateTaskById( 66 | templateTaskId: string, 67 | templateTaskSpecs: string, 68 | ): Promise { 69 | try { 70 | const result = await this.knex('tasks') 71 | .where({ id: templateTaskId }) 72 | .update({ spec: templateTaskSpecs }); 73 | this.logger.debug( 74 | `updateTemplateTaskById : Data selected successfully ${JSON.stringify( 75 | result, 76 | )}`, 77 | ); 78 | return result; 79 | } catch (error) { 80 | this.logger.error( 81 | 'Error selecting data:', 82 | error ? (error as Error) : undefined, 83 | ); 84 | throw error; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /scripts/verify-changesets.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2020 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint-disable @backstage/no-undeclared-imports */ 19 | 20 | const { resolve: resolvePath } = require('path'); 21 | const fs = require('fs-extra'); 22 | const { default: parseChangeset } = require('@changesets/parse'); 23 | 24 | const privatePackages = new Set([ 25 | 'example-app', 26 | 'example-backend', 27 | 'e2e-test', 28 | 'storybook', 29 | 'techdocs-cli-embedded-app', 30 | ]); 31 | 32 | async function main() { 33 | process.chdir(resolvePath(__dirname, '../.changeset')); 34 | 35 | const fileNames = await fs.readdir('.'); 36 | const changesetNames = fileNames.filter( 37 | name => name.endsWith('.md') && name !== 'README.md', 38 | ); 39 | 40 | const changesets = await Promise.all( 41 | changesetNames.map(async name => { 42 | const content = await fs.readFile(name, 'utf8'); 43 | return { name, ...parseChangeset(content) }; 44 | }), 45 | ); 46 | 47 | const errors = []; 48 | for (const changeset of changesets) { 49 | const privateReleases = changeset.releases.filter(release => 50 | privatePackages.has(release.name), 51 | ); 52 | if (privateReleases.length > 0) { 53 | const names = privateReleases 54 | .map(release => `'${release.name}'`) 55 | .join(', '); 56 | errors.push({ 57 | name: changeset.name, 58 | messages: [ 59 | `Should not contain releases of the following packages since they are not published: ${names}`, 60 | ], 61 | }); 62 | } 63 | } 64 | 65 | if (errors.length) { 66 | console.log(); 67 | console.log('***********************************************************'); 68 | console.log('* Changeset verification failed! *'); 69 | console.log('***********************************************************'); 70 | console.log(); 71 | for (const error of errors) { 72 | console.error(`Changeset '${error.name}' is invalid:`); 73 | console.log(); 74 | for (const message of error.messages) { 75 | console.error(` ${message}`); 76 | } 77 | } 78 | console.log(); 79 | console.log('***********************************************************'); 80 | console.log(); 81 | process.exit(1); 82 | } 83 | } 84 | 85 | main().catch(error => { 86 | console.error(error.stack); 87 | process.exit(1); 88 | }); 89 | -------------------------------------------------------------------------------- /scripts/assemble-manifest.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @backstage/no-undeclared-imports */ 3 | /* 4 | * Copyright 2022 The Backstage Authors 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | const semver = require('semver'); 20 | const fs = require('fs-extra'); 21 | const { getPackages } = require('@manypkg/get-packages'); 22 | const path = require('path'); 23 | 24 | async function main() { 25 | const [script, version] = process.argv.slice(1); 26 | if (!version) { 27 | throw new Error(`Argument must be ${script} `); 28 | } 29 | if (!semver.valid(version)) { 30 | throw new Error(`version '${version}' must be a valid semver`); 31 | } 32 | 33 | const manifestDir = path.resolve('versions', 'v1', 'releases', version); 34 | if (await fs.pathExists(manifestDir)) { 35 | throw new Error( 36 | `Release manifest path for version ${version} already exists`, 37 | ); 38 | } 39 | 40 | console.log(`Assembling packages for backstage release ${version}`); 41 | const { packages } = await getPackages(path.resolve('.')); 42 | 43 | const versions = packages 44 | .filter( 45 | p => 46 | (p.packageJson.name.startsWith('@backstage/') || 47 | p.packageJson.name.startsWith('@techdocs/')) && 48 | p.packageJson.private !== true, 49 | ) 50 | .map(p => { 51 | return { name: p.packageJson.name, version: p.packageJson.version }; 52 | }); 53 | await fs.ensureDir(manifestDir); 54 | await fs.writeJSON( 55 | path.resolve(manifestDir, 'manifest.json'), 56 | { releaseVersion: version, packages: versions }, 57 | { spaces: 2 }, 58 | ); 59 | const tag = version.includes('next') ? 'next' : 'main'; 60 | const tagPath = path.resolve('versions', 'v1', 'tags', tag); 61 | 62 | // Check if there's an existing version for the tag, and that it's not newer than the one we're adding 63 | if (await fs.pathExists(tagPath)) { 64 | const currentTag = await fs.readJSON( 65 | path.resolve(tagPath, 'manifest.json'), 66 | ); 67 | if (semver.gt(currentTag.releaseVersion, version)) { 68 | console.log( 69 | `Skipping update of ${tagPath} since current current ${tag} version is ${currentTag.releaseVersion}`, 70 | ); 71 | return; 72 | } 73 | } 74 | 75 | // Switch the tag to our new version 76 | await fs.remove(tagPath); 77 | await fs.ensureSymlink(path.join('..', 'releases', version), tagPath); 78 | } 79 | 80 | main().catch(error => { 81 | console.error(error.stack); 82 | process.exit(1); 83 | }); 84 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/service/standaloneServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { LoggerService } from '@backstage/backend-plugin-api'; 17 | import { 18 | DatabaseManager, 19 | createServiceBuilder, 20 | loadBackendConfig, 21 | HostDiscovery, 22 | UrlReaders, 23 | } from '@backstage/backend-common'; 24 | import { Server } from 'http'; 25 | import { createRouter } from './router'; 26 | import { ConfigReader } from '@backstage/config'; 27 | import { 28 | PluginTaskScheduler, 29 | TaskInvocationDefinition, 30 | TaskRunner, 31 | } from '@backstage/backend-tasks'; 32 | 33 | export interface ServerOptions { 34 | port: number; 35 | enableCors: boolean; 36 | logger: LoggerService; 37 | } 38 | 39 | export async function startStandaloneServer( 40 | options: ServerOptions, 41 | ): Promise { 42 | const logger = options.logger.child({ service: 'time-saver-backend' }); 43 | const config = await loadBackendConfig({ logger, argv: process.argv }); 44 | const discovery = HostDiscovery.fromConfig(config); 45 | 46 | class PersistingTaskRunner implements TaskRunner { 47 | private tasks: TaskInvocationDefinition[] = []; 48 | 49 | getTasks() { 50 | return this.tasks; 51 | } 52 | 53 | run(task: TaskInvocationDefinition): Promise { 54 | this.tasks.push(task); 55 | return Promise.resolve(undefined); 56 | } 57 | } 58 | 59 | const taskRunner = new PersistingTaskRunner(); 60 | const scheduler = { 61 | createScheduledTaskRunner: (_: unknown) => taskRunner, 62 | } as unknown as PluginTaskScheduler; 63 | // TODO : Validate createScheduledTaskRunner type 64 | 65 | const manager = DatabaseManager.fromConfig( 66 | new ConfigReader({ 67 | backend: { 68 | database: { client: 'better-sqlite3', connection: ':memory:' }, 69 | }, 70 | }), 71 | ); 72 | const database = manager.forPlugin('time-saver'); 73 | logger.debug('Starting application server...'); 74 | const router = await createRouter({ 75 | logger, 76 | config, 77 | database, 78 | discovery, 79 | scheduler, 80 | urlReader: UrlReaders.default({ logger, config }), 81 | }); 82 | 83 | let service = createServiceBuilder(module) 84 | .setPort(options.port) 85 | .addRouter('/time-saver', router); 86 | if (options.enableCors) { 87 | service = service.enableCors({ origin: 'http://localhost:3000' }); 88 | } 89 | 90 | return await service.start().catch(err => { 91 | logger.error(err); 92 | process.exit(1); 93 | }); 94 | } 95 | 96 | module.hot?.accept(); 97 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TeamSelectorComponent/TeamSelectorComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import { 20 | Button, 21 | Select, 22 | Box, 23 | FormControl, 24 | InputLabel, 25 | MenuItem, 26 | } from '@material-ui/core'; 27 | 28 | interface TeamSelectorProps { 29 | onTeamChange: (team: string) => void; 30 | onClearButtonClick?: () => void; 31 | } 32 | 33 | type GroupsResponse = { 34 | groups: string[]; 35 | }; 36 | 37 | export default function TeamSelector({ 38 | onTeamChange, 39 | onClearButtonClick, 40 | }: TeamSelectorProps): React.ReactElement { 41 | const [team, setTeam] = React.useState(''); 42 | 43 | const handleChange = ( 44 | event: React.ChangeEvent<{ 45 | name?: string | undefined; 46 | value: unknown; 47 | }>, 48 | ) => { 49 | const selectedTeam = event.target.value as string; 50 | setTeam(selectedTeam); 51 | onTeamChange(selectedTeam); 52 | }; 53 | 54 | const handleClearClick = () => { 55 | setTeam(''); 56 | onClearButtonClick?.(); 57 | }; 58 | 59 | const [data, setData] = useState(null); 60 | const configApi = useApi(configApiRef); 61 | const fetchApi = useApi(fetchApiRef); 62 | 63 | useEffect(() => { 64 | fetchApi 65 | .fetch(`${configApi.getString('backend.baseUrl')}/api/time-saver/groups`) 66 | .then(response => response.json()) 67 | .then(dt => setData(dt)) 68 | .catch(); 69 | }, [configApi, onTeamChange, fetchApi]); 70 | 71 | return ( 72 | <> 73 | {data?.groups ? ( 74 | 77 | 78 | Team 79 | 86 | 87 | {onClearButtonClick && ( 88 | 95 | )} 96 | 97 | ) : ( 98 | 99 | )} 100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/GroupDivisionPieChartComponent/GroupDivisionPieChartComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { 18 | Chart as ChartJS, 19 | Title, 20 | Tooltip, 21 | ChartOptions, 22 | ArcElement, 23 | } from 'chart.js'; 24 | import { Pie } from 'react-chartjs-2'; 25 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 26 | import { createUrlWithDates, getRandomColor } from '../utils'; 27 | import CircularProgress from '@material-ui/core/CircularProgress'; 28 | import { useTheme } from '@material-ui/core'; 29 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 30 | 31 | ChartJS.register(Title, Tooltip, ArcElement); 32 | 33 | type GroupDivisionPieChartResponse = { 34 | stats: { 35 | percentage: string; 36 | team: string; 37 | }[]; 38 | }; 39 | 40 | export function GroupDivisionPieChart({ 41 | dates, 42 | }: { 43 | dates: IFilterDates; 44 | }): React.ReactElement { 45 | const configApi = useApi(configApiRef); 46 | const fetchApi = useApi(fetchApiRef); 47 | 48 | const [data, setData] = useState(null); 49 | const theme = useTheme(); 50 | 51 | useEffect(() => { 52 | fetchApi 53 | .fetch( 54 | createUrlWithDates( 55 | `${configApi.getString( 56 | 'backend.baseUrl', 57 | )}/api/time-saver/getStats/group`, 58 | dates, 59 | ), 60 | ) 61 | .then(response => response.json()) 62 | .then(dt => setData(dt)) 63 | .catch(); 64 | }, [configApi, fetchApi, dates]); 65 | 66 | if (!data) { 67 | return ; 68 | } 69 | 70 | const options: ChartOptions<'pie'> = { 71 | plugins: { 72 | legend: { 73 | labels: { 74 | color: theme.palette.text.primary, 75 | }, 76 | }, 77 | title: { 78 | display: true, 79 | text: 'Team Percentage Distribution', 80 | color: theme.palette.text.primary, 81 | }, 82 | }, 83 | responsive: true, 84 | }; 85 | 86 | const labels = data.stats.map(stat => stat.team); 87 | const percentages = data.stats.map(stat => parseFloat(stat.percentage)); 88 | 89 | const backgroundColors = Array.from({ length: labels.length }, () => 90 | getRandomColor(), 91 | ); 92 | 93 | const dataAll = { 94 | labels, 95 | datasets: [ 96 | { 97 | data: percentages, 98 | backgroundColor: backgroundColors, 99 | hoverBackgroundColor: backgroundColors, 100 | }, 101 | ], 102 | }; 103 | 104 | return ; 105 | } 106 | -------------------------------------------------------------------------------- /plugins/time-saver/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tduniec/backstage-plugin-time-saver", 3 | "version": "2.1.0", 4 | "main": "src/index.ts", 5 | "types": "src/index.ts", 6 | "license": "Apache-2.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/tduniec/backstage-timesaver-plugin.git" 10 | }, 11 | "author": "tduniec ", 12 | "publishConfig": { 13 | "access": "public", 14 | "main": "dist/index.esm.js", 15 | "types": "dist/index.d.ts" 16 | }, 17 | "backstage": { 18 | "role": "frontend-plugin", 19 | "pluginId": "time-saver", 20 | "pluginPackages": [ 21 | "@tduniec/backstage-plugin-time-saver", 22 | "@tduniec/backstage-plugin-time-saver-backend", 23 | "@tduniec/backstage-plugin-time-saver-common" 24 | ] 25 | }, 26 | "sideEffects": false, 27 | "scripts": { 28 | "start": "backstage-cli package start", 29 | "build": "backstage-cli package build", 30 | "lint": "backstage-cli package lint", 31 | "test": "backstage-cli package test", 32 | "clean": "backstage-cli package clean", 33 | "prepack": "backstage-cli package prepack", 34 | "postpack": "backstage-cli package postpack" 35 | }, 36 | "dependencies": { 37 | "@backstage/core-components": "^0.14.9", 38 | "@backstage/core-plugin-api": "^1.9.3", 39 | "@backstage/theme": "^0.5.6", 40 | "@emotion/react": "^11.13.3", 41 | "@emotion/styled": "^11.13.0", 42 | "@material-ui/core": "^4.9.13", 43 | "@material-ui/icons": "^4.9.1", 44 | "@material-ui/lab": "^4.0.0-alpha.61", 45 | "@mui/material": "^6.1.3", 46 | "@mui/styles": "^5.15.6", 47 | "@mui/x-data-grid": "^6.19.1", 48 | "@mui/x-date-pickers": "^7.20.0", 49 | "@testing-library/jest-dom": "^6.4.6", 50 | "@testing-library/react": "^16.0.0", 51 | "@types/react-chartjs-2": "^2.5.7", 52 | "chart.js": "^4.4.1", 53 | "luxon": "^3.5.0", 54 | "react": "^18.3.1", 55 | "react-chartjs-2": "^5.2.0", 56 | "react-dom": "^18.3.1", 57 | "react-use": "^17.2.4" 58 | }, 59 | "peerDependencies": { 60 | "react": "^16.13.1 || ^17.0.0 || 18.3.1" 61 | }, 62 | "devDependencies": { 63 | "@backstage/cli": "^0.26.11", 64 | "@backstage/core-app-api": "^1.14.1", 65 | "@backstage/dev-utils": "^1.0.36", 66 | "@backstage/test-utils": "^1.5.9", 67 | "@backstage/version-bridge": "^1.0.8", 68 | "@testing-library/jest-dom": "^6.4.6", 69 | "@testing-library/react": "^14.3.1", 70 | "@testing-library/user-event": "^14.0.0", 71 | "@types/react": "^18.3.1", 72 | "@types/uuid": "^9.0.8", 73 | "msw": "^1.0.0", 74 | "react": "^18.3.1", 75 | "react-dom": "^18.3.1" 76 | }, 77 | "files": [ 78 | "dist", 79 | "config.d.ts" 80 | ], 81 | "description": "This plugin provides an implementation of charts and statistics related to your time savings that are coming from usage of your templates. Plugins is built from frontend and backend part. This part of plugin `frontend` is responsible of providing views with charts describing data collected from `backend` part of plugin.", 82 | "bugs": { 83 | "url": "https://github.com/tduniec/backstage-timesaver-plugin/issues" 84 | }, 85 | "homepage": "https://github.com/tduniec/backstage-timesaver-plugin#readme", 86 | "directories": { 87 | "doc": "docs" 88 | }, 89 | "keywords": [ 90 | "backstage", 91 | "time-saver" 92 | ], 93 | "configSchema": "config.d.ts" 94 | } 95 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/service/router.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { 17 | AuthService, 18 | LoggerService, 19 | RootConfigService, 20 | coreServices, 21 | createBackendPlugin, 22 | DiscoveryService, 23 | UrlReaderService, 24 | HttpAuthService, 25 | DatabaseService, 26 | } from '@backstage/backend-plugin-api'; 27 | import { 28 | errorHandler, 29 | createLegacyAuthAdapters, 30 | } from '@backstage/backend-common'; 31 | import { PluginTaskScheduler } from '@backstage/backend-tasks'; 32 | import express from 'express'; 33 | import Router from 'express-promise-router'; 34 | import { PluginInitializer } from './pluginInitializer'; 35 | 36 | export interface RouterOptions { 37 | logger: LoggerService; 38 | config: RootConfigService; 39 | discovery: DiscoveryService; 40 | database: DatabaseService; 41 | scheduler: PluginTaskScheduler; 42 | urlReader: UrlReaderService; 43 | auth?: AuthService; 44 | httpAuth?: HttpAuthService; 45 | } 46 | 47 | function registerRouter() { 48 | const router = Router(); 49 | router.use(express.json()); 50 | return router; 51 | } 52 | 53 | export async function createRouter( 54 | options: RouterOptions, 55 | ): Promise { 56 | const { logger, config, database, scheduler } = options; 57 | const baseRouter = registerRouter(); 58 | const { auth } = createLegacyAuthAdapters(options); 59 | const plugin = await PluginInitializer.builder( 60 | baseRouter, 61 | logger, 62 | config, 63 | auth, 64 | database, 65 | scheduler, 66 | ); 67 | const router = plugin.timeSaverRouter; 68 | router.use(errorHandler()); 69 | return router; 70 | } 71 | 72 | export const timeSaverPlugin = createBackendPlugin({ 73 | pluginId: 'time-saver', 74 | register(env) { 75 | env.registerInit({ 76 | deps: { 77 | logger: coreServices.logger, 78 | config: coreServices.rootConfig, 79 | auth: coreServices.auth, 80 | scheduler: coreServices.scheduler, 81 | database: coreServices.database, 82 | http: coreServices.httpRouter, 83 | httpRouter: coreServices.httpRouter, 84 | urlReader: coreServices.urlReader, 85 | }, 86 | async init({ 87 | auth, 88 | config, 89 | logger, 90 | scheduler, 91 | database, 92 | http, 93 | httpRouter, 94 | }) { 95 | const baseRouter = registerRouter(); 96 | const plugin = await PluginInitializer.builder( 97 | baseRouter, 98 | logger, 99 | config, 100 | auth, 101 | database, 102 | scheduler, 103 | ); 104 | const router = plugin.timeSaverRouter; 105 | http.use(router); 106 | 107 | httpRouter.addAuthPolicy({ 108 | path: '/migrate', 109 | allow: 'unauthenticated', 110 | }); 111 | 112 | httpRouter.addAuthPolicy({ 113 | path: '/generate-sample-classification', 114 | allow: 'unauthenticated', 115 | }); 116 | }, 117 | }); 118 | }, 119 | }); 120 | -------------------------------------------------------------------------------- /plugins/catalog-backend-module-time-saver-processor/src/processor/TimeSaverProcessor.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '@backstage/catalog-model'; 2 | import { TimeSaverProcessor } from './TimeSaverProcessor'; 3 | import { mockServices } from '@backstage/backend-test-utils'; 4 | 5 | function copy(entity: Entity): Entity { 6 | return JSON.parse(JSON.stringify(entity)); 7 | } 8 | 9 | describe('TimeSaverProcessor', () => { 10 | const logger = mockServices.logger.mock(); 11 | let processorUnderTest: TimeSaverProcessor; 12 | 13 | beforeEach(() => { 14 | processorUnderTest = new TimeSaverProcessor(logger); 15 | }); 16 | 17 | it('returns expected processor name', () => { 18 | expect(processorUnderTest.getProcessorName()).toEqual('TimeSaverProcessor'); 19 | }); 20 | 21 | it('appends expected time-saved annotation', async () => { 22 | const expectedEntity = { 23 | apiVersion: '1', 24 | kind: 'Template', 25 | metadata: { 26 | name: 'anything', 27 | substitute: { 28 | engineering: { 29 | devops: 8, 30 | security: 2, 31 | }, 32 | }, 33 | }, 34 | }; 35 | const entity = await processorUnderTest.preProcessEntity( 36 | copy(expectedEntity), 37 | ); 38 | expect(entity).toEqual({ 39 | apiVersion: '1', 40 | kind: 'Template', 41 | metadata: { 42 | name: 'anything', 43 | annotations: { 44 | 'backstage.io/time-saved': 'PT10H', 45 | }, 46 | substitute: { 47 | engineering: { 48 | devops: 8, 49 | security: 2, 50 | }, 51 | }, 52 | }, 53 | }); 54 | }); 55 | 56 | it('does not modify entity with no time-saver metadata', async () => { 57 | const expectedEntity = { 58 | apiVersion: '1', 59 | kind: 'Template', 60 | metadata: { 61 | name: 'anything', 62 | }, 63 | }; 64 | const entity = await processorUnderTest.preProcessEntity( 65 | copy(expectedEntity), 66 | ); 67 | expect(entity).toEqual(expectedEntity); 68 | }); 69 | 70 | it('does not modify non-template entity', async () => { 71 | const expectedEntity = { 72 | apiVersion: '1', 73 | kind: 'Component', 74 | metadata: { 75 | name: 'anything', 76 | substitute: { 77 | engineering: { 78 | devops: 8, 79 | security: 2, 80 | }, 81 | }, 82 | }, 83 | }; 84 | const entity = await processorUnderTest.preProcessEntity( 85 | copy(expectedEntity), 86 | ); 87 | expect(entity).toEqual(expectedEntity); 88 | }); 89 | 90 | it('does not modify entity that already has time-saved annotation', async () => { 91 | const expectedEntity = { 92 | apiVersion: '1', 93 | kind: 'Template', 94 | metadata: { 95 | name: 'anything', 96 | annotations: { 97 | 'backstage.io/time-saved': 'PT1D', 98 | }, 99 | substitute: { 100 | engineering: { 101 | devops: 8, 102 | security: 2, 103 | }, 104 | }, 105 | }, 106 | }; 107 | const entity = await processorUnderTest.preProcessEntity( 108 | copy(expectedEntity), 109 | ); 110 | expect(entity).toEqual(expectedEntity); 111 | }); 112 | 113 | it('does not modify entity with zero hours saved', async () => { 114 | const expectedEntity = { 115 | apiVersion: '1', 116 | kind: 'Template', 117 | metadata: { 118 | name: 'anything', 119 | substitute: { 120 | engineering: {}, 121 | }, 122 | }, 123 | }; 124 | const entity = await processorUnderTest.preProcessEntity( 125 | copy(expectedEntity), 126 | ); 127 | expect(entity).toEqual(expectedEntity); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/database/mappers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | roundNumericValues, 3 | isoDateFromDateTime, 4 | dateTimeFromIsoDate, 5 | } from '../utils'; 6 | import { 7 | GroupSavingsDivision, 8 | TemplateTimeSavingsDistinctRbRow, 9 | GroupSavingsDivisionDbRow, 10 | TemplateTimeSavingsDbRow, 11 | TimeSavedStatisticsDbRow, 12 | RawDbTimeSummary, 13 | TemplateTimeSavings, 14 | TimeSavedStatistics, 15 | TimeSummary, 16 | TemplateTimeSavingsCollection, 17 | } from './types'; 18 | 19 | const DEFAULT_DB_CREATED_AT_VALUE = ''; 20 | 21 | export class TemplateTimeSavingsMap { 22 | static toPersistence( 23 | templateTimeSavings: TemplateTimeSavings, 24 | ): TemplateTimeSavingsDbRow { 25 | return { 26 | team: templateTimeSavings.team, 27 | role: templateTimeSavings.role, 28 | created_at: 29 | isoDateFromDateTime(templateTimeSavings.createdAt) || 30 | DEFAULT_DB_CREATED_AT_VALUE, 31 | created_by: templateTimeSavings.createdBy, 32 | time_saved: templateTimeSavings.timeSaved, 33 | template_name: templateTimeSavings.templateName, 34 | template_task_id: templateTimeSavings.templateTaskId, 35 | template_task_status: templateTimeSavings.templateTaskStatus, 36 | }; 37 | } 38 | static toDTO( 39 | templateTimeSavingsDbRow: TemplateTimeSavingsDbRow, 40 | ): TemplateTimeSavings { 41 | return { 42 | id: templateTimeSavingsDbRow.id, 43 | team: templateTimeSavingsDbRow.team, 44 | role: templateTimeSavingsDbRow.role, 45 | createdAt: dateTimeFromIsoDate(templateTimeSavingsDbRow.created_at), 46 | createdBy: templateTimeSavingsDbRow.created_by, 47 | timeSaved: roundNumericValues(templateTimeSavingsDbRow.time_saved), 48 | templateName: templateTimeSavingsDbRow.template_name, 49 | templateTaskId: templateTimeSavingsDbRow.template_task_id, 50 | templateTaskStatus: templateTimeSavingsDbRow.template_task_status, 51 | }; 52 | } 53 | } 54 | 55 | export class TemplateTimeSavingsCollectionMap { 56 | static toDTO( 57 | templateTimeSavingsDbRows: TemplateTimeSavingsDbRow[], 58 | ): TemplateTimeSavingsCollection { 59 | return templateTimeSavingsDbRows.map(e => TemplateTimeSavingsMap.toDTO(e)); 60 | } 61 | static distinctToDTO( 62 | templateTimeSavingsDbRows: TemplateTimeSavingsDistinctRbRow[], 63 | ): { [x: string]: (string | number)[] } | undefined { 64 | if (!(templateTimeSavingsDbRows && templateTimeSavingsDbRows.length)) { 65 | return undefined; 66 | } 67 | const key: string = Object.keys(templateTimeSavingsDbRows[0])[0]; 68 | const values = templateTimeSavingsDbRows.map(e => Object.values(e)[0]); 69 | 70 | return { 71 | [key]: [...values], 72 | }; 73 | } 74 | } 75 | 76 | export class TimeSavedStatisticsMap { 77 | static toDTO( 78 | timeSavedStatisticsDbRow: TimeSavedStatisticsDbRow, 79 | ): TimeSavedStatistics { 80 | return { 81 | team: timeSavedStatisticsDbRow?.team, 82 | templateName: timeSavedStatisticsDbRow?.template_name, 83 | timeSaved: parseInt(timeSavedStatisticsDbRow?.time_saved || '0', 10), 84 | }; 85 | } 86 | } 87 | 88 | export class GroupSavingsDivisionMap { 89 | static toDTO( 90 | groupSavingsDivisionDbRow: GroupSavingsDivisionDbRow, 91 | ): GroupSavingsDivision { 92 | return { 93 | team: groupSavingsDivisionDbRow?.team, 94 | percentage: roundNumericValues(groupSavingsDivisionDbRow.percentage), 95 | }; 96 | } 97 | } 98 | 99 | export class TimeSummaryMap { 100 | static toDTO(timeSummaryDbRow: RawDbTimeSummary): TimeSummary { 101 | return { 102 | team: timeSummaryDbRow?.team, 103 | templateName: timeSummaryDbRow?.template_name, 104 | date: dateTimeFromIsoDate(timeSummaryDbRow.date), 105 | totalTimeSaved: 106 | roundNumericValues(timeSummaryDbRow.total_time_saved) || 0, 107 | }; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/timeSaver/handler.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { ScaffolderClient, TemplateTask } from '../api/scaffolderClient'; 17 | import { 18 | LoggerService, 19 | RootConfigService, 20 | AuthService, 21 | } from '@backstage/backend-plugin-api'; 22 | import { TimeSaverStore } from '../database/TimeSaverDatabase'; 23 | import { TemplateTimeSavings } from '../database/types'; 24 | import { DateTime } from 'luxon'; 25 | 26 | export class TimeSaverHandler { 27 | constructor( 28 | private readonly logger: LoggerService, 29 | private readonly config: RootConfigService, 30 | private readonly auth: AuthService, 31 | private readonly db: TimeSaverStore, 32 | ) {} 33 | 34 | async fetchTemplates(): Promise<'SUCCESS' | 'FAIL'> { 35 | const pageSize = 36 | this.config.getOptionalNumber('ts.scheduler.parallelProcessing') ?? 100; 37 | this.logger.debug(`SET parallelProcessing of tasks to: ${pageSize}`); 38 | const client = new ScaffolderClient(this.logger, this.config, this.auth); 39 | 40 | this.logger.info('START – Collecting Time Savings data from templates'); 41 | // exclusions 42 | let excludedSet = new Set(); 43 | try { 44 | const excluded = await this.db.getTasksToExclude(); 45 | if (Array.isArray(excluded)) excludedSet = new Set(excluded); 46 | } catch (e) { 47 | this.logger.error('Failed to load exclusion list', e as Error); 48 | return 'FAIL'; 49 | } 50 | 51 | await this.db.truncate(); // cleanup table 52 | 53 | // fetching templates for scaffolder using PAGE_SIZE 54 | for (let page = 0; ; page++) { 55 | this.logger.debug(`Fetching page ${page} (size=${pageSize})`); 56 | const tasks: TemplateTask[] = await client.fetchTemplatesFromScaffolder({ 57 | page, 58 | pageSize, 59 | }); 60 | if (tasks.length === 0) break; 61 | 62 | const rows: TemplateTimeSavings[] = []; 63 | for (const tpl of tasks) { 64 | if (tpl.status !== 'completed' || excludedSet.has(tpl.id)) { 65 | continue; 66 | } 67 | const subs = 68 | tpl.spec.templateInfo.entity.metadata.substitute?.engineering; 69 | if (!subs) { 70 | continue; 71 | } 72 | 73 | const createdAt = DateTime.fromISO(tpl.createdAt, { setZone: true }); 74 | if (!createdAt.isValid) { 75 | this.logger.error( 76 | `Invalid createdAt for template ${tpl.id}: ${tpl.createdAt}`, 77 | ); 78 | continue; 79 | } 80 | 81 | for (const [team, timeSaved] of Object.entries(subs)) { 82 | rows.push({ 83 | team, 84 | role: '', 85 | timeSaved, 86 | createdAt, 87 | createdBy: tpl.createdBy, 88 | templateName: tpl.spec.templateInfo.entityRef, 89 | templateTaskStatus: tpl.status, 90 | templateTaskId: tpl.id, 91 | }); 92 | } 93 | } 94 | 95 | if (rows.length) { 96 | this.logger.debug(`Inserting ${rows.length} rows`); 97 | await this.db.bulkInsertTimeSavings(rows); // one bulk insert per page 98 | } 99 | } 100 | 101 | this.logger.info('STOP – Collecting Time Savings data from templates'); 102 | return 'SUCCESS'; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/api/scaffolderClient.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { 17 | AuthService, 18 | LoggerService, 19 | RootConfigService, 20 | } from '@backstage/backend-plugin-api'; 21 | 22 | export interface TemplateTask { 23 | id: string; 24 | status: string; 25 | createdAt: string; 26 | createdBy: string; 27 | spec: { 28 | templateInfo: { 29 | [x: string]: any; 30 | entity: { 31 | metadata: { 32 | substitute?: { engineering: Record }; 33 | }; 34 | entityRef: string; 35 | }; 36 | }; 37 | }; 38 | } 39 | 40 | export class ScaffolderClient { 41 | constructor( 42 | private readonly logger: LoggerService, 43 | private readonly config: RootConfigService, 44 | private readonly auth: AuthService, 45 | ) {} 46 | 47 | /** 48 | * Fetch a page of templates with pagination support. 49 | */ 50 | async fetchTemplatesFromScaffolder( 51 | opts: { page?: number; pageSize?: number } = {}, 52 | ): Promise { 53 | const { page = 0, pageSize = 50 } = opts; 54 | // Resolve backend URL and work around localhost binding 55 | let backendUrl = 56 | this.config.getOptionalString('ts.backendUrl') ?? 'http://127.0.0.1:7007'; 57 | backendUrl = backendUrl.replace( 58 | /(http:\/\/)localhost(:\d+)/g, 59 | '$1127.0.0.1$2', 60 | ); 61 | const templatePath = '/api/scaffolder/v2/tasks'; 62 | const offset = page * pageSize; 63 | const callUrl = `${backendUrl}${templatePath}?limit=${pageSize}&offset=${offset}`; 64 | const token = await this.generateBackendToken(); 65 | 66 | try { 67 | const response = await fetch(callUrl, { 68 | method: 'GET', 69 | headers: { 70 | Authorization: `Bearer ${token}`, 71 | }, 72 | }); 73 | const data = await response.json(); 74 | this.logger.debug( 75 | `Scaffolder API response (page=${page}, size=${pageSize}): ${JSON.stringify( 76 | data, 77 | )}`, 78 | ); 79 | 80 | if (Object.hasOwn(data, 'error')) { 81 | this.logger.error('Error retrieving scaffolder tasks', data.error); 82 | return []; 83 | } 84 | if (!Array.isArray(data.tasks)) { 85 | this.logger.error('Unexpected response: tasks array missing'); 86 | return []; 87 | } 88 | return data.tasks; 89 | } catch (error) { 90 | this.logger.error(`Failed to fetch from ${callUrl}`, error as Error); 91 | return []; 92 | } 93 | } 94 | 95 | /** 96 | * Stream all templates, page by page, yielding each task as it arrives. 97 | */ 98 | async *streamTemplatesFromScaffolder( 99 | pageSize = 50, 100 | ): AsyncGenerator { 101 | let page = 0; 102 | while (true) { 103 | const batch = await this.fetchTemplatesFromScaffolder({ page, pageSize }); 104 | if (!batch.length) break; 105 | for (const task of batch) { 106 | yield task; 107 | } 108 | page++; 109 | } 110 | } 111 | 112 | async generateBackendToken(): Promise { 113 | const { token } = await this.auth.getPluginRequestToken({ 114 | onBehalfOf: await this.auth.getOwnServiceCredentials(), 115 | targetPluginId: 'scaffolder', 116 | }); 117 | return token; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/BarChartComponent/BarChartComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { 18 | Chart as ChartJS, 19 | CategoryScale, 20 | LinearScale, 21 | BarElement, 22 | Title, 23 | Tooltip, 24 | ChartOptions, 25 | } from 'chart.js'; 26 | import { Bar } from 'react-chartjs-2'; 27 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 28 | import { getRandomColor } from '../utils'; 29 | import CircularProgress from '@material-ui/core/CircularProgress'; 30 | import { useTheme } from '@material-ui/core'; 31 | 32 | ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip); 33 | 34 | type SingleTemplateChartResponse = { 35 | templateTaskId: string; 36 | templateName: string; 37 | stats: { 38 | timeSaved: number; 39 | team: string; 40 | }[]; 41 | }; 42 | 43 | interface BarChartProps { 44 | templateTaskId: string; 45 | } 46 | 47 | export function BarChart({ 48 | templateTaskId, 49 | }: BarChartProps): React.ReactElement { 50 | const configApi = useApi(configApiRef); 51 | const fetchApi = useApi(fetchApiRef); 52 | 53 | const [data, setData] = useState(null); 54 | 55 | const theme = useTheme(); 56 | 57 | useEffect(() => { 58 | fetchApi 59 | .fetch( 60 | `${configApi.getString( 61 | 'backend.baseUrl', 62 | )}/api/time-saver/getStats?templateTaskId=${templateTaskId} `, 63 | ) 64 | .then(response => response.json()) 65 | .then(dt => setData(dt)) 66 | .catch(); 67 | }, [configApi, templateTaskId, fetchApi]); 68 | 69 | if (!data) { 70 | return ; 71 | } 72 | 73 | const options: ChartOptions<'bar'> = { 74 | plugins: { 75 | title: { 76 | display: true, 77 | text: data.templateName || '', 78 | color: theme.palette.text.primary, 79 | }, 80 | legend: { 81 | display: true, 82 | labels: { 83 | color: theme.palette.text.primary, 84 | }, 85 | }, 86 | }, 87 | responsive: true, 88 | interaction: { 89 | mode: 'index', 90 | intersect: false, 91 | }, 92 | scales: { 93 | x: { 94 | stacked: true, 95 | grid: { 96 | display: false, 97 | color: theme.palette.text.primary, 98 | }, 99 | ticks: { 100 | color: theme.palette.text.primary, 101 | }, 102 | }, 103 | y: { 104 | stacked: true, 105 | grid: { 106 | display: false, 107 | color: theme.palette.text.primary, 108 | }, 109 | ticks: { 110 | color: theme.palette.text.primary, 111 | }, 112 | }, 113 | }, 114 | }; 115 | 116 | const labels = Array.from(new Set(data.stats.map(stat => stat.team))); 117 | const datasets = data.stats.map(stat => stat.timeSaved); 118 | 119 | const backgroundColors = Array.from({ length: datasets.length }, () => 120 | getRandomColor(), 121 | ); 122 | const dataAll = { 123 | labels, 124 | datasets: [ 125 | { 126 | label: 'Time Saved', 127 | data: datasets, 128 | backgroundColor: backgroundColors, 129 | }, 130 | ], 131 | }; 132 | 133 | return ; 134 | } 135 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/ByTeamBarCharComponent/ByTeamBarChartComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { 18 | Chart as ChartJS, 19 | CategoryScale, 20 | LinearScale, 21 | BarElement, 22 | Title, 23 | Tooltip, 24 | ChartOptions, 25 | } from 'chart.js'; 26 | import { Bar } from 'react-chartjs-2'; 27 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 28 | import { createUrlWithDates, getRandomColor } from '../utils'; 29 | import CircularProgress from '@material-ui/core/CircularProgress'; 30 | import { useTheme } from '@material-ui/core'; 31 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 32 | 33 | ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip); 34 | 35 | type TeamChartResponse = { 36 | team: string; 37 | stats: { 38 | timeSaved: number; 39 | templateName: string; 40 | }[]; 41 | }; 42 | 43 | interface ByTeamBarChartProps { 44 | team: string; 45 | dates: IFilterDates; 46 | } 47 | 48 | export function ByTeamBarChart({ 49 | team, 50 | dates, 51 | }: ByTeamBarChartProps): React.ReactElement { 52 | const configApi = useApi(configApiRef); 53 | const fetchApi = useApi(fetchApiRef); 54 | 55 | const [data, setData] = useState(null); 56 | 57 | const theme = useTheme(); 58 | 59 | useEffect(() => { 60 | fetchApi 61 | .fetch( 62 | createUrlWithDates( 63 | `${configApi.getString( 64 | 'backend.baseUrl', 65 | )}/api/time-saver/getStats?team=${team} `, 66 | dates, 67 | ), 68 | ) 69 | .then(response => response.json()) 70 | .then(dt => setData(dt)) 71 | .catch(); 72 | }, [configApi, team, fetchApi, dates]); 73 | 74 | if (!data) { 75 | return ; 76 | } 77 | 78 | const options: ChartOptions<'bar'> = { 79 | plugins: { 80 | title: { 81 | display: true, 82 | text: data.team || '', 83 | color: theme.palette.text.primary, 84 | }, 85 | legend: { 86 | display: true, 87 | labels: { 88 | color: theme.palette.text.primary, 89 | }, 90 | }, 91 | }, 92 | responsive: true, 93 | interaction: { 94 | mode: 'index', 95 | intersect: false, 96 | }, 97 | scales: { 98 | x: { 99 | stacked: true, 100 | grid: { 101 | display: true, 102 | }, 103 | ticks: { 104 | color: theme.palette.text.primary, 105 | }, 106 | }, 107 | y: { 108 | stacked: true, 109 | grid: { 110 | display: true, 111 | }, 112 | ticks: { 113 | color: theme.palette.text.primary, 114 | }, 115 | }, 116 | }, 117 | }; 118 | 119 | const labels = Array.from(new Set(data.stats.map(stat => stat.templateName))); 120 | const datasets = data.stats.map(stat => stat.timeSaved); 121 | 122 | const backgroundColors = Array.from({ length: datasets.length }, () => 123 | getRandomColor(), 124 | ); 125 | const dataAll = { 126 | labels, 127 | datasets: [ 128 | { 129 | label: 'Time Saved', 130 | data: datasets, 131 | backgroundColor: backgroundColors, 132 | }, 133 | ], 134 | }; 135 | 136 | return ; 137 | } 138 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/ByTemplateBarCharComponent/ByTemplateBarChartComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { 18 | Chart as ChartJS, 19 | CategoryScale, 20 | LinearScale, 21 | BarElement, 22 | Title, 23 | Tooltip, 24 | ChartOptions, 25 | } from 'chart.js'; 26 | import { Bar } from 'react-chartjs-2'; 27 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 28 | import { createUrlWithDates, getRandomColor } from '../utils'; 29 | import CircularProgress from '@material-ui/core/CircularProgress'; 30 | import { useTheme } from '@material-ui/core'; 31 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 32 | 33 | ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip); 34 | 35 | type TemplateChartResponse = { 36 | templateName: string; 37 | stats: { 38 | timeSaved: number; 39 | team: string; 40 | }[]; 41 | }; 42 | 43 | interface ByTemplateBarChartProps { 44 | templateName: string; 45 | dates: IFilterDates; 46 | } 47 | 48 | export function ByTemplateBarChart({ 49 | templateName, 50 | dates, 51 | }: ByTemplateBarChartProps): React.ReactElement { 52 | const configApi = useApi(configApiRef); 53 | const fetchApi = useApi(fetchApiRef); 54 | const [data, setData] = useState(null); 55 | const theme = useTheme(); 56 | 57 | useEffect(() => { 58 | fetchApi 59 | .fetch( 60 | createUrlWithDates( 61 | `${configApi.getString( 62 | 'backend.baseUrl', 63 | )}/api/time-saver/getStats?templateName=${templateName}`, 64 | dates, 65 | ), 66 | ) 67 | .then(response => response.json()) 68 | .then(dt => setData(dt)) 69 | .catch(); 70 | }, [configApi, templateName, fetchApi, dates]); 71 | 72 | if (!data) { 73 | return ; 74 | } 75 | 76 | const options: ChartOptions<'bar'> = { 77 | plugins: { 78 | title: { 79 | display: true, 80 | text: data.templateName || '', 81 | color: theme.palette.text.primary, 82 | }, 83 | legend: { 84 | display: true, 85 | labels: { 86 | color: theme.palette.text.primary, 87 | }, 88 | }, 89 | }, 90 | responsive: true, 91 | interaction: { 92 | mode: 'index', 93 | intersect: false, 94 | }, 95 | scales: { 96 | x: { 97 | stacked: true, 98 | grid: { 99 | display: true, 100 | }, 101 | ticks: { 102 | color: theme.palette.text.primary, 103 | }, 104 | }, 105 | y: { 106 | stacked: true, 107 | grid: { 108 | display: true, 109 | }, 110 | ticks: { 111 | color: theme.palette.text.primary, 112 | }, 113 | }, 114 | }, 115 | }; 116 | 117 | const labels = Array.from(new Set(data.stats.map(stat => stat.team))); 118 | const datasets = data.stats.map(stat => stat.timeSaved); 119 | 120 | const backgroundColors = Array.from({ length: datasets.length }, () => 121 | getRandomColor(), 122 | ); 123 | const dataAll = { 124 | labels, 125 | datasets: [ 126 | { 127 | label: 'Time Saved', 128 | data: datasets, 129 | backgroundColor: backgroundColors, 130 | }, 131 | ], 132 | }; 133 | 134 | return ; 135 | } 136 | -------------------------------------------------------------------------------- /scripts/generate-merge-message.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2022 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const { execFile: execFileCb } = require('child_process'); 19 | const { promisify } = require('util'); 20 | const { resolve: resolvePath } = require('path'); 21 | 22 | const execFile = promisify(execFileCb); 23 | 24 | async function hasNewChangesets(baseRef, headRef) { 25 | if (!baseRef) { 26 | throw new Error('baseRef is required'); 27 | } 28 | if (!headRef) { 29 | throw new Error('headRef is required'); 30 | } 31 | 32 | const { stdout } = await execFile('git', [ 33 | 'diff', 34 | '--compact-summary', 35 | baseRef, 36 | headRef, 37 | '--', 38 | '.changeset/*.md', 39 | ':(exclude).changeset/create-app-*.md', 40 | ]); 41 | return stdout.includes('(new)'); 42 | } 43 | 44 | function getReleaseOfMonth(year, month) { 45 | const base = new Date(Date.UTC(year, month)); 46 | const wednesdayOffset = 47 | base.getUTCDay() > 3 ? 10 - base.getUTCDay() : 3 - base.getUTCDay(); 48 | const thirdWednesdayOffset = wednesdayOffset + 7 * 2; 49 | const releaseOffset = thirdWednesdayOffset - 1; 50 | const releaseDay = new Date( 51 | Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), releaseOffset + 1), 52 | ); 53 | return releaseDay; 54 | } 55 | 56 | function getReleaseSchedule() { 57 | const firstReleaseYear = 2022; 58 | const firstReleaseMonth = 2; 59 | 60 | return Array(100) 61 | .fill(0) 62 | .map((_, i) => { 63 | const date = getReleaseOfMonth(firstReleaseYear, firstReleaseMonth + i); 64 | return { version: `1.${i}.0`, date }; 65 | }); 66 | } 67 | 68 | function getCurrentRelease() { 69 | const { version: releaseVersion } = require(resolvePath('package.json')); 70 | 71 | const match = releaseVersion.match(/^(\d+\.\d+\.\d+)/); 72 | if (!match) { 73 | throw new Error(`Failed to parse release version, '${releaseVersion}'`); 74 | } 75 | const [versionStr] = match; 76 | if (versionStr === releaseVersion) { 77 | return releaseVersion; 78 | } 79 | const [major, minor] = versionStr.split('.').map(Number); 80 | return `${major}.${minor - 1}.0`; 81 | } 82 | 83 | function findNextRelease(currentRelease, releaseSchedule) { 84 | const currentIndex = releaseSchedule.findIndex( 85 | r => r.version === currentRelease, 86 | ); 87 | if (currentIndex === -1) { 88 | throw new Error( 89 | `Failed to find current release '${currentRelease}' in release schedule`, 90 | ); 91 | } 92 | 93 | return releaseSchedule[currentIndex + 1]; 94 | } 95 | 96 | async function main() { 97 | const [diffBaseRefRef = 'origin/master', diffHeadRef = 'HEAD'] = 98 | process.argv.slice(2); 99 | const needsMessage = await hasNewChangesets(diffBaseRefRef, diffHeadRef); 100 | if (!needsMessage) { 101 | return; 102 | } 103 | 104 | const currentRelease = getCurrentRelease(); 105 | const releaseSchedule = getReleaseSchedule(); 106 | const nextRelease = findNextRelease(currentRelease, releaseSchedule); 107 | 108 | const scheduledDate = nextRelease.date 109 | .toUTCString() 110 | .replace(/\s*\d+:\d+:\d+.*/, ''); 111 | process.stdout.write( 112 | [ 113 | 'Thank you for contributing to Backstage! The changes in this pull request will be part', 114 | `of the \`${nextRelease.version}\` release, scheduled for ${scheduledDate}.`, 115 | ].join(' '), 116 | ); 117 | } 118 | 119 | main().catch(error => { 120 | console.error(error.stack); 121 | process.exit(1); 122 | }); 123 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/AllStatsBarChartComponent/AllStatsBarChartComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { 18 | Chart as ChartJS, 19 | CategoryScale, 20 | LinearScale, 21 | BarElement, 22 | Title, 23 | Tooltip, 24 | ChartOptions, 25 | } from 'chart.js'; 26 | import { Bar } from 'react-chartjs-2'; 27 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 28 | import { createUrlWithDates, getRandomColor } from '../utils'; 29 | import CircularProgress from '@material-ui/core/CircularProgress'; 30 | import { useTheme } from '@material-ui/core'; 31 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 32 | 33 | ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip); 34 | 35 | type AllStatsChartResponse = { 36 | stats: { 37 | timeSaved: number; 38 | team: string; 39 | templateName: string; 40 | }[]; 41 | }; 42 | 43 | export function AllStatsBarChart({ 44 | dates, 45 | }: { 46 | dates: IFilterDates; 47 | }): React.ReactElement { 48 | const configApi = useApi(configApiRef); 49 | const fetchApi = useApi(fetchApiRef); 50 | 51 | const [data, setData] = useState(null); 52 | const theme = useTheme(); 53 | 54 | useEffect(() => { 55 | fetchApi 56 | .fetch( 57 | createUrlWithDates( 58 | `${configApi.getString('backend.baseUrl')}/api/time-saver/getStats`, 59 | dates, 60 | ), 61 | ) 62 | .then(response => response.json()) 63 | .then(dt => setData(dt)) 64 | .catch(); 65 | }, [configApi, fetchApi, dates]); 66 | 67 | if (!data) { 68 | return ; 69 | } 70 | 71 | const options: ChartOptions<'bar'> = { 72 | plugins: { 73 | title: { 74 | display: true, 75 | text: 'All Statistics', 76 | color: theme.palette.text.primary, 77 | }, 78 | legend: { 79 | display: true, 80 | labels: { 81 | color: theme.palette.text.primary, 82 | }, 83 | }, 84 | }, 85 | responsive: true, 86 | interaction: { 87 | mode: 'index', 88 | intersect: false, 89 | }, 90 | scales: { 91 | x: { 92 | stacked: true, 93 | grid: { 94 | display: false, 95 | }, 96 | ticks: { 97 | color: theme.palette.text.primary, 98 | }, 99 | }, 100 | y: { 101 | stacked: true, 102 | grid: { 103 | display: true, 104 | }, 105 | ticks: { 106 | color: theme.palette.text.primary, 107 | }, 108 | }, 109 | }, 110 | }; 111 | 112 | const labels = Array.from(new Set(data.stats.map(stat => stat.team))); 113 | const datasets = Array.from( 114 | new Set(data.stats.map(stat => stat.templateName)), 115 | ); 116 | 117 | const backgroundColors = datasets.map(() => getRandomColor()); 118 | 119 | const dataAll = { 120 | labels, 121 | datasets: datasets.map((templateName, index) => ({ 122 | label: `${templateName}`, 123 | data: labels.map(team => 124 | data.stats 125 | .filter( 126 | stat => stat.team === team && stat.templateName === templateName, 127 | ) 128 | .reduce((sum, stat) => sum + stat.timeSaved, 0), 129 | ), 130 | backgroundColor: backgroundColors[index], 131 | })), 132 | }; 133 | 134 | return ; 135 | } 136 | -------------------------------------------------------------------------------- /scripts/check-if-release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @backstage/no-undeclared-imports */ 3 | /* 4 | * Copyright 2020 The Backstage Authors 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | // This script is used to determine whether a particular commit has changes 20 | // that should lead to a release. It is run as part of the main master build 21 | // to determine whether the release flow should be run as well. 22 | // 23 | // It has the following output which can be used later in GitHub actions: 24 | // 25 | // needs_release = 'true' | 'false' 26 | 27 | const { execFile: execFileCb } = require('child_process'); 28 | const { resolve: resolvePath } = require('path'); 29 | const { promises: fs } = require('fs'); 30 | const { promisify } = require('util'); 31 | const { EOL } = require('os'); 32 | 33 | const parentRef = process.env.COMMIT_SHA_BEFORE || 'HEAD^'; 34 | 35 | const execFile = promisify(execFileCb); 36 | 37 | async function runPlain(cmd, ...args) { 38 | try { 39 | const { stdout } = await execFile(cmd, args, { shell: true }); 40 | return stdout.trim(); 41 | } catch (error) { 42 | if (error.stderr) { 43 | process.stderr.write(error.stderr); 44 | } 45 | if (!error.code) { 46 | throw error; 47 | } 48 | throw new Error( 49 | `Command '${[cmd, ...args].join(' ')}' failed with code ${error.code}`, 50 | ); 51 | } 52 | } 53 | 54 | async function main() { 55 | process.cwd(resolvePath(__dirname, '..')); 56 | 57 | if (!process.env.GITHUB_OUTPUT) { 58 | throw new Error('GITHUB_OUTPUT environment variable not set'); 59 | } 60 | 61 | const diff = await runPlain( 62 | 'git', 63 | 'diff', 64 | '--name-only', 65 | parentRef, 66 | "'*/package.json'", // Git treats this as what would usually be **/package.json 67 | ); 68 | const packageList = diff 69 | .split('\n') 70 | .filter(path => path.match(/^(packages|plugins)\/[^/]+\/package\.json$/)); 71 | 72 | const packageVersions = await Promise.all( 73 | packageList.map(async path => { 74 | let name; 75 | let newVersion; 76 | let oldVersion; 77 | 78 | try { 79 | const data = JSON.parse( 80 | await runPlain('git', 'show', `${parentRef}:${path}`), 81 | ); 82 | name = data.name; 83 | oldVersion = data.version; 84 | } catch { 85 | oldVersion = ''; 86 | } 87 | 88 | try { 89 | const data = JSON.parse(await fs.readFile(path, 'utf8')); 90 | name = data.name; 91 | newVersion = data.version; 92 | } catch (error) { 93 | if (error.code === 'ENOENT') { 94 | newVersion = ''; 95 | } 96 | } 97 | 98 | return { name, oldVersion, newVersion }; 99 | }), 100 | ); 101 | 102 | const newVersions = packageVersions.filter( 103 | ({ oldVersion, newVersion }) => 104 | oldVersion !== newVersion && 105 | oldVersion !== '' && 106 | newVersion !== '', 107 | ); 108 | 109 | if (newVersions.length === 0) { 110 | console.log('No package version bumps detected, no release needed'); 111 | await fs.appendFile(process.env.GITHUB_OUTPUT, `needs_release=false${EOL}`); 112 | return; 113 | } 114 | 115 | console.log('Package version bumps detected, a new release is needed'); 116 | const maxLength = Math.max(...newVersions.map(_ => _.name.length)); 117 | for (const { name, oldVersion, newVersion } of newVersions) { 118 | console.log( 119 | ` ${name.padEnd(maxLength, ' ')} ${oldVersion} to ${newVersion}`, 120 | ); 121 | } 122 | await fs.appendFile(process.env.GITHUB_OUTPUT, `needs_release=true${EOL}`); 123 | } 124 | 125 | main().catch(error => { 126 | console.error(error.stack); 127 | process.exit(1); 128 | }); 129 | -------------------------------------------------------------------------------- /plugins/time-saver-backend/src/service/router.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { DiscoveryService } from '@backstage/backend-plugin-api'; 17 | import { UrlReaders } from '@backstage/backend-defaults/urlReader'; 18 | import { DatabaseManager } from '@backstage/backend-defaults/database'; 19 | import express from 'express'; 20 | import request from 'supertest'; 21 | 22 | import { createRouter } from './router'; 23 | // import { CatalogRequestOptions } from '@backstage/catalog-client'; 24 | import { ConfigReader } from '@backstage/config'; 25 | import { 26 | PluginTaskScheduler, 27 | TaskInvocationDefinition, 28 | TaskRunner, 29 | } from '@backstage/backend-tasks'; 30 | import { mockServices } from '@backstage/backend-test-utils'; 31 | 32 | // let catalogRequestOptions: CatalogRequestOptions; 33 | 34 | const testDiscovery: jest.Mocked = { 35 | getBaseUrl: jest 36 | .fn() 37 | .mockResolvedValue('http://localhost:7007/api/time-saver'), 38 | getExternalBaseUrl: jest.fn(), 39 | }; 40 | const mockUrlReader = UrlReaders.default({ 41 | logger: mockServices.logger.mock(), 42 | config: new ConfigReader({}), 43 | }); 44 | 45 | describe('createRouter', () => { 46 | let app: express.Express; 47 | const manager = DatabaseManager.fromConfig( 48 | new ConfigReader({ 49 | backend: { 50 | database: { client: 'better-sqlite3', connection: ':memory:' }, 51 | }, 52 | }), 53 | ); 54 | const config = new ConfigReader({ 55 | backend: { 56 | baseUrl: 'http://127.0.0.1', 57 | listen: { port: 7007 }, 58 | database: { 59 | client: 'better-sqlite3', 60 | connection: ':memory:', 61 | }, 62 | }, 63 | }); 64 | const database = manager.forPlugin('time-saver'); 65 | class PersistingTaskRunner implements TaskRunner { 66 | private tasks: TaskInvocationDefinition[] = []; 67 | 68 | getTasks() { 69 | return this.tasks; 70 | } 71 | 72 | run(task: TaskInvocationDefinition): Promise { 73 | this.tasks.push(task); 74 | return Promise.resolve(undefined); 75 | } 76 | } 77 | 78 | const taskRunner = new PersistingTaskRunner(); 79 | const scheduler = { 80 | createScheduledTaskRunner: (_: unknown) => taskRunner, 81 | } as unknown as PluginTaskScheduler; 82 | // TODO : validate createScheduledTaskRunner parameters types. 83 | 84 | beforeAll(async () => { 85 | // const discovery = HostDiscovery.fromConfig(config); 86 | // const router = await createRouter({ 87 | // database: database, 88 | // logger: getVoidLogger(), 89 | // discovery: discovery, 90 | // config: config, 91 | // scheduler: scheduler, 92 | // }); 93 | // app = express().use(router); 94 | const router = await createRouter({ 95 | // config: new ConfigReader({}), 96 | config: config, 97 | logger: mockServices.logger.mock(), 98 | // database: createDatabase(), 99 | database: database, 100 | discovery: testDiscovery, 101 | urlReader: mockUrlReader, 102 | scheduler: scheduler, 103 | auth: mockServices.auth(), 104 | httpAuth: mockServices.httpAuth(), 105 | }); 106 | app = express().use(router); 107 | }); 108 | 109 | beforeEach(() => { 110 | jest.resetAllMocks(); 111 | }); 112 | 113 | const itTestGETApiEndpoint = ( 114 | label: string, 115 | endpoint: string, 116 | status: object, 117 | ) => { 118 | it(`${label}`, async () => { 119 | const response = await request(app).get(endpoint); 120 | 121 | expect(response.status).toEqual(200); 122 | expect(response.body).toEqual(status); 123 | }); 124 | }; 125 | 126 | describe('GET /health', () => { 127 | itTestGETApiEndpoint('returns ok', '/health', { status: 'ok' }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /scripts/check-docs-quality.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | const { spawnSync } = require('child_process'); 17 | const { 18 | resolve: resolvePath, 19 | join: joinPath, 20 | relative: relativePath, 21 | } = require('path'); 22 | const fs = require('fs').promises; 23 | 24 | const IGNORED = [ 25 | /^ADOPTERS\.md$/, 26 | /^OWNERS\.md$/, 27 | /^.*[/\\]CHANGELOG\.md$/, 28 | /^.*[/\\]([^\/]+-)?api-report\.md$/, 29 | /^docs[/\\]releases[/\\].*-changelog\.md$/, 30 | /^docs[/\\]reference[/\\]/, 31 | ]; 32 | 33 | const rootDir = resolvePath(__dirname, '..'); 34 | 35 | // Manual listing to avoid dependency install for listing files in CI 36 | async function listFiles(dir = '') { 37 | const files = await fs.readdir(dir || rootDir); 38 | const paths = await Promise.all( 39 | files 40 | .filter(file => file !== 'node_modules') 41 | .map(async file => { 42 | const path = joinPath(dir, file); 43 | 44 | if (IGNORED.some(pattern => pattern.test(path))) { 45 | return []; 46 | } 47 | if ((await fs.stat(path)).isDirectory()) { 48 | return listFiles(path); 49 | } 50 | if (!path.endsWith('.md')) { 51 | return []; 52 | } 53 | return path; 54 | }), 55 | ); 56 | return paths.flat(); 57 | } 58 | 59 | // Proceed with the script only if Vale linter is installed. Limit the friction and surprises 60 | // caused by the script. In CI, we want to ensure vale linter is run. 61 | async function exitIfMissingVale() { 62 | try { 63 | // eslint-disable-next-line @backstage/no-undeclared-imports 64 | await require('command-exists')('vale'); 65 | } catch (e) { 66 | if (process.env.CI) { 67 | console.log( 68 | `Language linter (vale) was not found. Please install vale linter (https://docs.errata.ai/vale/install).\n`, 69 | ); 70 | process.exit(1); 71 | } 72 | console.log(`Language linter (vale) generated errors. Please check the errors and review any markdown files that you changed. 73 | Possibly update .github/vale/config/vocabularies/Backstage/accept.txt to add new valid words.\n`); 74 | process.exit(0); 75 | } 76 | } 77 | 78 | async function runVale(files) { 79 | const result = spawnSync( 80 | 'vale', 81 | ['--config', resolvePath(rootDir, '.vale.ini'), ...files], 82 | { 83 | stdio: 'inherit', 84 | }, 85 | ); 86 | 87 | if (result.status !== 0) { 88 | // TODO(Rugvip): This logic was here before but seems a bit odd, could use some verification on windows. 89 | // If it contains system level error. In this case vale does not exist. 90 | if (process.platform !== 'win32' || result.error) { 91 | console.log(`Language linter (vale) generated errors. Please check the errors and review any markdown files that you changed. 92 | Possibly update .github/vale/config/vocabularies/Backstage/accept.txt to add new valid words.\n`); 93 | } 94 | return false; 95 | } 96 | 97 | return true; 98 | } 99 | 100 | async function main() { 101 | if (process.argv.includes('--ci-args')) { 102 | const files = await listFiles(); 103 | 104 | process.stdout.write( 105 | // Workaround for not being able to pass arguments to the vale action 106 | JSON.stringify([...files]), 107 | ); 108 | return; 109 | } 110 | 111 | await exitIfMissingVale(); 112 | 113 | const absolutePaths = process.argv 114 | .slice(2) 115 | .filter(path => !path.startsWith('-')); 116 | const relativePaths = absolutePaths.map(path => relativePath(rootDir, path)); 117 | 118 | const success = await runVale( 119 | relativePaths.length === 0 ? await listFiles() : relativePaths, 120 | ); 121 | if (!success) { 122 | process.exit(2); 123 | } 124 | } 125 | 126 | main().catch(error => { 127 | console.error(error); 128 | process.exit(1); 129 | }); 130 | -------------------------------------------------------------------------------- /scripts/verify-lockfile-duplicates.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2020 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint-disable @backstage/no-undeclared-imports */ 19 | 20 | const { execFile: execFileCb } = require('child_process'); 21 | const { resolve: resolvePath, dirname: dirnamePath } = require('path'); 22 | const { promisify } = require('util'); 23 | 24 | const execFile = promisify(execFileCb); 25 | 26 | async function findLockFiles() { 27 | const projectRoot = resolvePath(__dirname, '..'); 28 | 29 | let files = process.argv.slice(2).filter(arg => !arg.startsWith('--')); 30 | 31 | for (const argumentFile of files) { 32 | if (!argumentFile.match(/(?:^|[\/\\])yarn.lock$/)) { 33 | throw new Error(`Not a yarn.lock file path argument: "${argumentFile}"`); 34 | } 35 | } 36 | 37 | if (!files.length) { 38 | // List all lock files that are in the root or in an immediate subdirectory 39 | files = ['yarn.lock', 'microsite/yarn.lock']; 40 | } 41 | 42 | return files.map(file => ({ 43 | fileRelativeToProjectRoot: file, 44 | directoryRelativeToProjectRoot: dirnamePath(file), 45 | directoryAbsolute: resolvePath(projectRoot, dirnamePath(file)), 46 | })); 47 | } 48 | 49 | async function main() { 50 | const lockFiles = await findLockFiles(); 51 | 52 | let fix = false; 53 | for (const arg of process.argv) { 54 | if (arg.startsWith('--')) { 55 | if (arg === '--fix') { 56 | fix = true; 57 | } else { 58 | throw new Error(`Unknown argument ${arg}`); 59 | } 60 | } 61 | } 62 | 63 | for (const lockFile of lockFiles) { 64 | console.log('Checking lock file', lockFile.fileRelativeToProjectRoot); 65 | 66 | let stdout; 67 | let stderr; 68 | let failed; 69 | 70 | try { 71 | const result = await execFile( 72 | 'yarn', 73 | ['dedupe', ...(fix ? [] : ['--check'])], 74 | { 75 | shell: true, 76 | cwd: lockFile.directoryAbsolute, 77 | }, 78 | ); 79 | stdout = result.stdout?.trim(); 80 | stderr = result.stderr?.trim(); 81 | failed = false; 82 | } catch (error) { 83 | stdout = error.stdout?.trim(); 84 | stderr = error.stderr?.trim(); 85 | failed = true; 86 | } 87 | 88 | if (stdout) { 89 | console.log(stdout); 90 | } 91 | 92 | if (stderr) { 93 | console.error(stderr); 94 | } 95 | 96 | if (failed) { 97 | if (!fix) { 98 | const command = `yarn dedupe${ 99 | lockFile.directoryRelativeToProjectRoot === '.' 100 | ? '' 101 | : ` --cwd ${lockFile.directoryRelativeToProjectRoot}` 102 | }`; 103 | const padding = ' '.repeat(Math.max(0, 85 - 6 - command.length)); 104 | console.error(''); 105 | console.error( 106 | '*************************************************************************************', 107 | ); 108 | console.error( 109 | '* You have duplicate versions of some packages in a yarn.lock file. *', 110 | ); 111 | console.error( 112 | '* To solve this, run the following command from the project root and commit all *', 113 | ); 114 | console.log( 115 | '* yarn.lock changes. *', 116 | ); 117 | console.log( 118 | '* *', 119 | ); 120 | console.log(`* ${command}${padding} *`); 121 | console.error( 122 | '*************************************************************************************', 123 | ); 124 | console.error(''); 125 | } 126 | 127 | process.exit(1); 128 | } 129 | } 130 | } 131 | 132 | main().catch(error => { 133 | console.error(error.stack); 134 | process.exit(1); 135 | }); 136 | -------------------------------------------------------------------------------- /scripts/run-fossa.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2020 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | // This script generates an appropriate fossa config, and wraps the running 19 | // of `fossa analyze` in a retry loop as it frequently fails with a 502 error 20 | 21 | const { resolve: resolvePath, join: joinPath, basename } = require('path'); 22 | const { promises: fs } = require('fs'); 23 | const { execFile: execFileCb } = require('child_process'); 24 | const { promisify } = require('util'); 25 | 26 | const execFile = promisify(execFileCb); 27 | 28 | const FOSSA_YAML_HEAD = ` 29 | version: 2 30 | cli: 31 | server: https://app.fossa.com 32 | fetcher: custom 33 | project: backstage 34 | analyze: 35 | modules:`; 36 | 37 | const IGNORED_DIRS = ['node_modules', 'dist', 'bin', '.git']; 38 | 39 | // Finds all directories containing package.json files that we're interested in analyzing 40 | async function findPackageJsonDirs(dir, depth = 0) { 41 | if (depth > 2) { 42 | return []; // Skipping packages that are deeper than 2 dirs in 43 | } 44 | const files = await fs.readdir(dir); 45 | const paths = await Promise.all( 46 | files 47 | .filter(file => !IGNORED_DIRS.includes(file)) 48 | .map(async file => { 49 | const path = joinPath(dir, file); 50 | 51 | if ((await fs.stat(path)).isDirectory()) { 52 | return findPackageJsonDirs(path, depth + 1); 53 | } else if (file === 'package.json') { 54 | return dir; 55 | } 56 | return []; 57 | }), 58 | ); 59 | return paths.flat(); 60 | } 61 | 62 | // A replacement for `fossa init`, as that generates a bad config for this repo 63 | async function generateConfig(paths) { 64 | let content = FOSSA_YAML_HEAD; 65 | 66 | for (const path of paths) { 67 | content += ` 68 | - name: ${basename(path)} 69 | type: npm 70 | path: ${path} 71 | target: ${path} 72 | options: 73 | strategy: yarn-list 74 | `; 75 | } 76 | 77 | return content; 78 | } 79 | 80 | // Runs `fossa analyze`, with 502 errors being retried up to 3 times 81 | async function runAnalyze(githubRef) { 82 | for (let attempt = 1; attempt <= 3; attempt++) { 83 | console.error(`Running fossa analyze, attempt ${attempt}`); 84 | try { 85 | const { stdout, stderr } = await execFile( 86 | 'fossa', 87 | ['analyze', '--branch', githubRef], 88 | { shell: true }, 89 | ); 90 | console.error(stderr); 91 | console.log(stdout); 92 | 93 | return; // Analyze was successful, we're done 94 | } catch (error) { 95 | if (!error.code) { 96 | throw error; 97 | } 98 | if (error.stderr) { 99 | process.stderr.write(error.stderr); 100 | } 101 | if (error.stdout) { 102 | process.stdout.write(error.stdout); 103 | } 104 | if (error.stderr && error.stderr.includes('502 Bad Gateway')) { 105 | console.error('Encountered 502 during fossa analysis upload, retrying'); 106 | continue; 107 | } 108 | throw new Error(`Fossa analyze failed with code ${error.code}`); 109 | } 110 | } 111 | 112 | console.error('Maximum number of retries reached, skipping fossa analysis'); 113 | } 114 | 115 | async function main() { 116 | const githubRef = process.env.GITHUB_REF; 117 | if (!githubRef) { 118 | throw new Error('GITHUB_REF is not set'); 119 | } 120 | // This is picked up by the fossa CLI and should be set 121 | if (!process.env.FOSSA_API_KEY) { 122 | throw new Error('FOSSA_API_KEY is not set'); 123 | } 124 | 125 | process.cwd(resolvePath(__dirname, '..')); 126 | 127 | const packageJsonPaths = await findPackageJsonDirs('.'); 128 | 129 | const configContents = await generateConfig(packageJsonPaths); 130 | 131 | await fs.writeFile('.fossa.yml', configContents, 'utf8'); 132 | 133 | console.error(`Generated fossa config:\n${configContents}`); 134 | 135 | await runAnalyze(githubRef); 136 | } 137 | 138 | main().catch(error => { 139 | console.error(error.stack); 140 | process.exit(1); 141 | }); 142 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TeamWiseTimeSummaryLinearComponent/TeamWiseTimeSummaryLinearComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import React, { useEffect, useState } from 'react'; 18 | import { 19 | Chart as ChartJS, 20 | LineElement, 21 | PointElement, 22 | Title, 23 | Tooltip, 24 | Legend, 25 | ChartOptions, 26 | } from 'chart.js'; 27 | import { Line } from 'react-chartjs-2'; 28 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 29 | import { createUrlWithDates, getRandomColor } from '../utils'; 30 | 31 | ChartJS.register(LineElement, PointElement, Title, Tooltip, Legend); 32 | import CircularProgress from '@material-ui/core/CircularProgress'; 33 | import { useTheme } from '@material-ui/core'; 34 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 35 | 36 | type TeamWiseTimeSummaryLinearResponse = { 37 | stats: { 38 | date: string; 39 | team: string; 40 | totalTimeSaved: number; 41 | }[]; 42 | }; 43 | 44 | interface TeamWiseTimeSummaryLinearProps { 45 | team?: string; 46 | dates: IFilterDates; 47 | } 48 | 49 | export function TeamWiseTimeSummaryLinearChart({ 50 | team, 51 | dates, 52 | }: TeamWiseTimeSummaryLinearProps): React.ReactElement { 53 | const configApi = useApi(configApiRef); 54 | const fetchApi = useApi(fetchApiRef); 55 | 56 | const [data, setData] = useState( 57 | null, 58 | ); 59 | const theme = useTheme(); 60 | useEffect(() => { 61 | fetchApi 62 | .fetch( 63 | createUrlWithDates( 64 | `${configApi.getString( 65 | 'backend.baseUrl', 66 | )}/api/time-saver/getTimeSummary/team`, 67 | dates, 68 | ), 69 | ) 70 | .then(response => response.json()) 71 | .then(dt => { 72 | dt.stats.sort( 73 | ( 74 | a: { date: string | number | Date }, 75 | b: { date: string | number | Date }, 76 | ) => new Date(a.date).getTime() - new Date(b.date).getTime(), 77 | ); 78 | setData(dt); 79 | }) 80 | .catch(); 81 | }, [configApi, team, fetchApi, dates]); 82 | 83 | if (!data) { 84 | return ; 85 | } 86 | 87 | let filteredData: TeamWiseTimeSummaryLinearResponse; 88 | if (team) { 89 | filteredData = { 90 | stats: data.stats.filter(stat => stat.team === team), 91 | }; 92 | } else { 93 | filteredData = data; 94 | } 95 | 96 | const uniqueTeams = Array.from( 97 | new Set(filteredData.stats.map(stat => stat.team)), 98 | ); 99 | 100 | const options: ChartOptions<'line'> = { 101 | plugins: { 102 | title: { 103 | display: true, 104 | text: 'Time Summary by Team', 105 | color: theme.palette.text.primary, 106 | }, 107 | }, 108 | responsive: true, 109 | scales: { 110 | x: [ 111 | { 112 | type: 'time', 113 | time: { 114 | unit: 'day', 115 | tooltipFormat: 'YYYY-MM-DD', 116 | displayFormats: { 117 | day: 'YYYY-MM-DD', 118 | }, 119 | bounds: 'data', 120 | }, 121 | scaleLabel: { 122 | display: true, 123 | labelString: 'Date', 124 | }, 125 | }, 126 | ] as unknown as ChartOptions<'line'>['scales'], 127 | y: [ 128 | { 129 | stacked: true, 130 | beginAtZero: true, 131 | scaleLabel: { 132 | display: true, 133 | labelString: 'Total Time Saved', 134 | }, 135 | }, 136 | ] as unknown as ChartOptions<'line'>['scales'], 137 | }, 138 | }; 139 | 140 | const uniqueDates = Array.from(new Set(data.stats.map(stat => stat.date))); 141 | 142 | const allData = { 143 | labels: uniqueDates, 144 | datasets: uniqueTeams.map(tm => { 145 | const templateData = filteredData.stats 146 | .filter(stat => stat.team === tm) 147 | .map(stat => ({ x: stat.date, y: stat.totalTimeSaved })); 148 | 149 | return { 150 | label: tm, 151 | data: templateData, 152 | fill: false, 153 | borderColor: getRandomColor(), 154 | }; 155 | }), 156 | }; 157 | 158 | return ; 159 | } 160 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/Table/StatsTable.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useState, useEffect } from 'react'; 17 | 18 | import CircularProgress from '@material-ui/core/CircularProgress'; 19 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 20 | import { DataGrid, GridColDef, GridSortModel } from '@mui/x-data-grid'; 21 | import { useTheme, Paper } from '@material-ui/core'; 22 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 23 | import { createUrlWithDates } from '../utils'; 24 | 25 | type Stat = { 26 | id: string; 27 | timeSaved: number; 28 | team: string; 29 | templateName: string; 30 | [key: string]: string | number; 31 | }; 32 | 33 | type AllStatsChartResponse = { 34 | stats: Stat[]; 35 | }; 36 | 37 | interface StatsTableProps { 38 | team?: string; 39 | templateName?: string; 40 | dates: IFilterDates; 41 | } 42 | 43 | const StatsTable: React.FC = ({ 44 | team, 45 | templateName, 46 | dates, 47 | }) => { 48 | const [data, setData] = useState(null); 49 | const [sortModel, setSortModel] = useState([ 50 | { field: 'sum', sort: 'asc' }, 51 | ]); 52 | 53 | const configApi = useApi(configApiRef); 54 | const fetchApi = useApi(fetchApiRef); 55 | 56 | const showTimeInDays = 57 | configApi.getOptionalBoolean('ts.frontend.table.showInDays') ?? false; 58 | const hoursPerDay = 59 | configApi.getOptionalNumber('ts.frontend.table.hoursPerDay') ?? 8; 60 | 61 | const theme = useTheme(); 62 | 63 | useEffect(() => { 64 | let url = `${configApi.getString( 65 | 'backend.baseUrl', 66 | )}/api/time-saver/getStats`; 67 | if (team) { 68 | url = `${url}?team=${team}`; 69 | } else if (templateName) { 70 | url = `${url}?templateName=${templateName}`; 71 | } 72 | 73 | fetchApi 74 | .fetch(createUrlWithDates(url, dates)) 75 | .then(response => response.json()) 76 | .then((dt: AllStatsChartResponse) => { 77 | const statsWithIds = dt.stats.map((stat, index) => ({ 78 | ...stat, 79 | id: index.toString(), 80 | })); 81 | setData(statsWithIds); 82 | setSortModel([{ field: 'sum', sort: 'desc' }]); 83 | }) 84 | .catch(); 85 | }, [configApi, team, templateName, fetchApi, dates]); 86 | 87 | if (!data) { 88 | return ; 89 | } 90 | 91 | const columns: GridColDef[] = [ 92 | { field: 'team', headerName: 'Team', flex: 1, sortable: true }, 93 | { 94 | field: 'templateName', 95 | headerName: 'Template Name', 96 | flex: 1, 97 | sortable: true, 98 | }, 99 | { 100 | field: 'timeSaved', 101 | headerName: showTimeInDays ? 'Saved Time [days]' : 'Saved Time [hours]', 102 | flex: 1, 103 | sortable: true, 104 | valueGetter: (params: { row: { timeSaved: number } }) => { 105 | const timeInHours = params.row.timeSaved as number; 106 | if (showTimeInDays) { 107 | return (timeInHours / hoursPerDay).toFixed(0); 108 | } 109 | return timeInHours; 110 | }, 111 | }, 112 | ].filter(col => data.some(row => !!row[col.field])); 113 | 114 | return ( 115 | 124 | setSortModel(model)} 129 | // className="test" :: TODO : Check CSS correlation 130 | sx={{ 131 | color: theme.palette.text.primary, 132 | '& .MuiDataGrid-cell:hover': { color: theme.palette.text.secondary }, 133 | '& .MuiDataGrid-footerContainer': { 134 | color: theme.palette.text.primary, 135 | }, 136 | '& .v5-MuiToolbar-root': { 137 | color: theme.palette.text.primary, 138 | }, 139 | '& .v5-MuiTablePagination-actions button': { 140 | color: theme.palette.text.primary, 141 | }, 142 | }} 143 | /> 144 | 145 | ); 146 | }; 147 | 148 | export default StatsTable; 149 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TemplateWiseDailyTimeLinearComponent/TemplateWiseWiseDailyTimeLinearComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import React, { useEffect, useState } from 'react'; 18 | import { 19 | Chart as ChartJS, 20 | LineElement, 21 | PointElement, 22 | Title, 23 | Tooltip, 24 | Legend, 25 | ChartOptions, 26 | } from 'chart.js'; 27 | import { Line } from 'react-chartjs-2'; 28 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 29 | import { createUrlWithDates, getRandomColor } from '../utils'; 30 | import CircularProgress from '@material-ui/core/CircularProgress'; 31 | import { useTheme } from '@material-ui/core'; 32 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 33 | 34 | ChartJS.register(LineElement, PointElement, Title, Tooltip, Legend); 35 | 36 | type DailyTimeSummaryResponse = { 37 | stats: { 38 | date: string; 39 | templateName: string; 40 | totalTimeSaved: number; 41 | }[]; 42 | }; 43 | 44 | interface DailyTimeSummaryLineProps { 45 | templateName?: string; 46 | dates: IFilterDates; 47 | } 48 | 49 | export function DailyTimeSummaryLineChartTemplateWise({ 50 | templateName, 51 | dates, 52 | }: DailyTimeSummaryLineProps): React.ReactElement { 53 | const configApi = useApi(configApiRef); 54 | const fetchApi = useApi(fetchApiRef); 55 | 56 | const [data, setData] = useState(null); 57 | const theme = useTheme(); 58 | useEffect(() => { 59 | const url = createUrlWithDates( 60 | `${configApi.getString( 61 | 'backend.baseUrl', 62 | )}/api/time-saver/getDailyTimeSummary/template`, 63 | dates, 64 | ); 65 | fetchApi 66 | .fetch(url) 67 | .then(response => response.json()) 68 | .then(dt => { 69 | dt.stats.sort( 70 | ( 71 | a: { date: string | number | Date }, 72 | b: { date: string | number | Date }, 73 | ) => new Date(a.date).getTime() - new Date(b.date).getTime(), 74 | ); 75 | setData(dt); 76 | }) 77 | .catch(); 78 | }, [configApi, templateName, fetchApi, dates]); 79 | 80 | if (!data) { 81 | return ; 82 | } 83 | 84 | let filteredData: DailyTimeSummaryResponse; 85 | if (templateName) { 86 | filteredData = { 87 | stats: data.stats.filter(stat => stat.templateName === templateName), 88 | }; 89 | } else { 90 | filteredData = data; 91 | } 92 | 93 | const uniqueTemplates = Array.from( 94 | new Set(filteredData.stats.map(stat => stat.templateName)), 95 | ); 96 | 97 | const options: ChartOptions<'line'> = { 98 | plugins: { 99 | title: { 100 | display: true, 101 | text: 'Daily Time Summary by Template', 102 | color: theme.palette.text.primary, 103 | }, 104 | }, 105 | responsive: true, 106 | scales: { 107 | x: [ 108 | { 109 | color: theme.palette.text.primary, 110 | type: 'time', 111 | time: { 112 | unit: 'day', 113 | tooltipFormat: 'YYYY-MM-DD', 114 | displayFormats: { 115 | day: 'YYYY-MM-DD', 116 | }, 117 | bounds: 'data', 118 | }, 119 | scaleLabel: { 120 | display: true, 121 | labelString: 'Date', 122 | }, 123 | }, 124 | ] as unknown as ChartOptions<'line'>['scales'], 125 | y: [ 126 | { 127 | stacked: true, 128 | beginAtZero: true, 129 | color: theme.palette.text.primary, 130 | 131 | scaleLabel: { 132 | display: true, 133 | labelString: 'Total Time Saved', 134 | }, 135 | }, 136 | ] as unknown as ChartOptions<'line'>['scales'], 137 | }, 138 | }; 139 | const uniqueDates = Array.from(new Set(data.stats.map(stat => stat.date))); 140 | 141 | const allData = { 142 | labels: uniqueDates, 143 | datasets: uniqueTemplates.map(tn => { 144 | const templateData = filteredData.stats 145 | .filter(stat => stat.templateName === tn) 146 | .map(stat => ({ x: stat.date, y: stat.totalTimeSaved })); 147 | 148 | return { 149 | label: tn, 150 | data: templateData, 151 | fill: false, 152 | borderColor: getRandomColor(), 153 | }; 154 | }), 155 | }; 156 | 157 | return ; 158 | } 159 | -------------------------------------------------------------------------------- /plugins/time-saver/README.md: -------------------------------------------------------------------------------- 1 | # Time Saver 2 | 3 | This plugin provides an implementation of charts and statistics related to your time savings that are coming from usage of your templates. Plugins is built from frontend and backend part. This part of plugin `frontend` is responsible of providing views with charts describing data collected from `backend` part of plugin. 4 | 5 | ## Dependencies 6 | 7 | - [time-saver-backend](https://github.com/tduniec/backstage-timesaver-plugin/tree/main/plugins/time-saver-backend) 8 | - [time-saver-common](https://github.com/tduniec/backstage-timesaver-plugin/tree/main/plugins/time-saver-common) 9 | 10 | ## Code 11 | 12 | https://github.com/tduniec/backstage-timesaver-plugin.git 13 | 14 | ## Screens 15 | 16 | ![Screenshot of the AllStats plugin Charts](https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/main/plugins/time-saver/docs/tsAllStats.png) 17 | ![Screenshot of the AllStats2 plugin Charts](https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/main/plugins/time-saver/docs/tsAllStats2.png) 18 | ![Screenshot of the ByTeam plugin Charts](https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/main/plugins/time-saver/docs/tsByTeam.png) 19 | ![Screenshot of the ByTemplate plugin Charts](https://raw.githubusercontent.com/tduniec/backstage-timesaver-plugin/main/plugins/time-saver/docs/tsByTemplate.png) 20 | 21 | ## Installation 22 | 23 | 1. Install the plugin package in your Backstage app: 24 | 25 | ```sh 26 | # From your Backstage root directory 27 | yarn add --cwd packages/app @tduniec/backstage-plugin-time-saver 28 | ``` 29 | 30 | 2. Now open the `packages/app/src/App.tsx` file 31 | 3. Then after all the import statements add the following line: 32 | 33 | ```tsx 34 | import { TimeSaverPage } from '@tduniec/backstage-plugin-time-saver'; 35 | ``` 36 | 37 | 4. In this same file just before the closing ``, this will be near the bottom of the file, add this line: 38 | 39 | ```tsx 40 | } /> 41 | ``` 42 | 43 | 5. Next open the `packages/app/src/components/Root/Root.tsx` file 44 | 6. We want to add this icon import after all the existing import statements: 45 | 46 | ```tsx 47 | import Timelapse from '@material-ui/icons/Timelapse'; 48 | ``` 49 | 50 | 7. Then add this line just after the `` line: 51 | 52 | ```tsx 53 | 54 | ``` 55 | 56 | 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) 57 | 9. Install [time-saver-backend](../time-saver-backend/README.md) part if not installed already 58 | 59 | ## Generate Statistics 60 | 61 | Configure your template definition like described below: 62 | Provide an object under `metadata`. Provide quantities of saved time by each group executing one template in **_hours_** preferably 63 | 64 | ```diff 65 | apiVersion: scaffolder.backstage.io/v1beta3 66 | kind: Template 67 | metadata: 68 | name: example-template 69 | title: create-github-project 70 | description: Creates Github project 71 | + substitute: 72 | + engineering: 73 | + devops: 1 74 | + security: 4 75 | + development_team: 2 76 | spec: 77 | owner: group:default/backstage-admins 78 | type: service 79 | ``` 80 | 81 | Scheduler is running with its default setup every **5 minutes** to generate data from executed templates with these information. 82 | 83 | ## Migration 84 | 85 | This plugins supports backward compatibility with migration. You can specify your Time Saver metadata for each template name. Then the migration will be performed once executing the API request to `/migrate` endpoint of the plugin. 86 | 87 | Configure your backward time savings here: 88 | 89 | Open the `app-config.yaml` file 90 | 91 | ```yaml 92 | ts: 93 | backward: 94 | config: | 95 | [ 96 | { 97 | "entityRef": "template:default/create-github-project", 98 | "engineering": { 99 | "devops": 8, 100 | "development_team": 8, 101 | "security": 3 102 | } 103 | } 104 | ] 105 | # extend this list if needed 106 | ``` 107 | 108 | ## TimeSaverPage optional customization 109 | 110 | 1. TimeSaverPage has now exported props that can help you customize your page headers. By default it is an empty header 111 | 112 | ```tsx 113 | 118 | ``` 119 | 120 | 2. Stats table config: 121 | 122 | By default Table stats Time Summaries are provided in hours, below you can find optional config that can change it to days. 123 | 124 | ```yaml 125 | ts: 126 | frontend: 127 | table: 128 | showInDays: true # if true, the table shows days [boolean] 129 | hoursPerDay: 8 # how many hours count as a day [number] 130 | ``` 131 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TeamWiseDailyTimeLinearComponent/TeamWiseDailyTimeLinearComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import React, { useEffect, useState } from 'react'; 18 | import { 19 | Chart as ChartJS, 20 | LineElement, 21 | PointElement, 22 | Title, 23 | Tooltip, 24 | Legend, 25 | ChartOptions, 26 | } from 'chart.js'; 27 | import { Line } from 'react-chartjs-2'; 28 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 29 | import { createUrlWithDates, getRandomColor } from '../utils'; 30 | import CircularProgress from '@material-ui/core/CircularProgress'; 31 | import { useTheme } from '@material-ui/core'; 32 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 33 | 34 | ChartJS.register(LineElement, PointElement, Title, Tooltip, Legend); 35 | 36 | type DailyTimeSummaryResponse = { 37 | stats: { 38 | date: string; 39 | team: string; 40 | totalTimeSaved: number; 41 | }[]; 42 | }; 43 | 44 | interface DailyTimeSummaryLineProps { 45 | team?: string; 46 | dates: IFilterDates; 47 | } 48 | 49 | export function DailyTimeSummaryLineChartTeamWise({ 50 | team, 51 | dates, 52 | }: DailyTimeSummaryLineProps): React.ReactElement { 53 | const configApi = useApi(configApiRef); 54 | const fetchApi = useApi(fetchApiRef); 55 | 56 | const [data, setData] = useState(null); 57 | const theme = useTheme(); 58 | useEffect(() => { 59 | fetchApi 60 | .fetch( 61 | createUrlWithDates( 62 | `${configApi.getString( 63 | 'backend.baseUrl', 64 | )}/api/time-saver/getDailyTimeSummary/team`, 65 | dates, 66 | ), 67 | ) 68 | .then(response => response.json()) 69 | .then(dt => { 70 | dt.stats.sort( 71 | ( 72 | a: { date: string | number | Date }, 73 | b: { date: string | number | Date }, 74 | ) => new Date(a.date).getTime() - new Date(b.date).getTime(), 75 | ); 76 | setData(dt); 77 | }) 78 | .catch(); 79 | }, [configApi, team, fetchApi, dates]); 80 | 81 | if (!data) { 82 | return ; 83 | } 84 | let filteredData: DailyTimeSummaryResponse; 85 | if (team) { 86 | filteredData = { 87 | stats: data.stats.filter(stat => stat.team === team), 88 | }; 89 | } else { 90 | filteredData = data; 91 | } 92 | const uniqueTeams = Array.from( 93 | new Set(filteredData.stats.map(stat => stat.team)), 94 | ); 95 | 96 | const options: ChartOptions<'line'> = { 97 | plugins: { 98 | title: { 99 | display: true, 100 | text: 'Daily Time Summary by Team', 101 | color: theme.palette.text.primary, 102 | }, 103 | }, 104 | responsive: true, 105 | scales: { 106 | x: [ 107 | { 108 | type: 'time', 109 | time: { 110 | unit: 'day', 111 | tooltipFormat: 'YYYY-MM-DD', 112 | displayFormats: { 113 | day: 'YYYY-MM-DD', 114 | }, 115 | }, 116 | scaleLabel: { 117 | display: true, 118 | labelString: 'Date', 119 | }, 120 | }, 121 | ] as unknown as ChartOptions<'line'>['scales'], 122 | y: [ 123 | { 124 | stacked: true, 125 | beginAtZero: true, 126 | scaleLabel: { 127 | display: true, 128 | labelString: 'Total Time Saved', 129 | }, 130 | }, 131 | ] as unknown as ChartOptions<'line'>['scales'], 132 | }, 133 | }; 134 | 135 | const uniqueDates = Array.from(new Set(data.stats.map(stat => stat.date))); 136 | 137 | const allData = { 138 | labels: uniqueDates, 139 | datasets: uniqueTeams.map(tm => { 140 | const templateData = filteredData.stats 141 | .filter((stat: { team: string | undefined }) => stat.team === tm) 142 | .map( 143 | (stat: { 144 | date: string | undefined; 145 | totalTimeSaved: number | undefined; 146 | }) => ({ 147 | x: stat.date, 148 | y: stat.totalTimeSaved, 149 | }), 150 | ); 151 | 152 | return { 153 | label: tm, 154 | data: templateData, 155 | fill: false, 156 | borderColor: getRandomColor(), 157 | }; 158 | }), 159 | }; 160 | // TODO : Verify date and total_time_saved types 161 | 162 | return ; 163 | } 164 | -------------------------------------------------------------------------------- /plugins/time-saver/src/components/TemplateWiseTimeSummaryLinearComponent/TemplateWiseTimeSummaryLinearComponent.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Backstage Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import React, { useEffect, useState } from 'react'; 17 | import { 18 | Chart as ChartJS, 19 | LineElement, 20 | PointElement, 21 | Title, 22 | Tooltip, 23 | Legend, 24 | ChartOptions, 25 | } from 'chart.js'; 26 | import { Line } from 'react-chartjs-2'; 27 | import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; 28 | import { createUrlWithDates, getRandomColor } from '../utils'; 29 | import CircularProgress from '@material-ui/core/CircularProgress'; 30 | import { useTheme } from '@material-ui/core'; 31 | import { IFilterDates } from '../DateFiltersComponent/DateFiltersComponent'; 32 | 33 | ChartJS.register(LineElement, PointElement, Title, Tooltip, Legend); 34 | 35 | type TemplateWiseTimeSummaryLinearResponse = { 36 | stats: { 37 | date: string; 38 | templateName: string; 39 | totalTimeSaved: number; 40 | }[]; 41 | }; 42 | 43 | interface TemplateWiseTimeSummaryLinearProps { 44 | templateName?: string; 45 | dates: IFilterDates; 46 | } 47 | 48 | export function TemplateWiseTimeSummaryLinearChart({ 49 | templateName, 50 | dates, 51 | }: TemplateWiseTimeSummaryLinearProps): React.ReactElement { 52 | const configApi = useApi(configApiRef); 53 | const fetchApi = useApi(fetchApiRef); 54 | const [data, setData] = 55 | useState(null); 56 | const theme = useTheme(); 57 | 58 | useEffect(() => { 59 | const url = createUrlWithDates( 60 | `${configApi.getString( 61 | 'backend.baseUrl', 62 | )}/api/time-saver/getTimeSummary/template`, 63 | dates, 64 | ); 65 | 66 | fetchApi 67 | .fetch(url) 68 | .then(response => response.json()) 69 | .then(dt => { 70 | dt.stats.sort( 71 | (a: { date: string }, b: { date: string }) => 72 | new Date(a.date).getTime() - new Date(b.date).getTime(), 73 | ); 74 | setData(dt); 75 | }) 76 | .catch(); 77 | }, [configApi, templateName, fetchApi, dates]); 78 | 79 | if (!data) { 80 | return ; 81 | } 82 | 83 | let filteredData: TemplateWiseTimeSummaryLinearResponse; 84 | if (templateName) { 85 | filteredData = { 86 | stats: data.stats.filter( 87 | (stat: { templateName: string }) => stat.templateName === templateName, 88 | ), 89 | }; 90 | } else { 91 | filteredData = data; 92 | } 93 | 94 | const uniqueTemplates = Array.from( 95 | new Set( 96 | filteredData.stats.map( 97 | (stat: { templateName: string }) => stat.templateName, 98 | ), 99 | ), 100 | ); 101 | 102 | const options: ChartOptions<'line'> = { 103 | plugins: { 104 | title: { 105 | display: true, 106 | text: 'Time Summary by Template', 107 | color: theme.palette.text.primary, 108 | }, 109 | }, 110 | responsive: true, 111 | scales: { 112 | x: [ 113 | { 114 | type: 'time', 115 | time: { 116 | unit: 'day', 117 | tooltipFormat: 'YYYY-MM-DD', 118 | displayFormats: { 119 | day: 'YYYY-MM-DD', 120 | }, 121 | bounds: 'data', 122 | }, 123 | scaleLabel: { 124 | display: true, 125 | labelString: 'Date', 126 | }, 127 | }, 128 | ] as unknown as ChartOptions<'line'>['scales'], 129 | y: [ 130 | { 131 | stacked: true, 132 | beginAtZero: true, 133 | scaleLabel: { 134 | display: true, 135 | labelString: 'Total Time Saved', 136 | }, 137 | }, 138 | ] as unknown as ChartOptions<'line'>['scales'], 139 | }, 140 | }; 141 | 142 | const uniqueDates = Array.from(new Set(data.stats.map(stat => stat.date))); 143 | 144 | const allData = { 145 | labels: uniqueDates, 146 | datasets: uniqueTemplates.map(tn => { 147 | const templateData = filteredData.stats 148 | .filter((stat: { templateName: string }) => stat.templateName === tn) 149 | .map( 150 | (stat: { 151 | date: string | undefined; 152 | totalTimeSaved: number | undefined; 153 | }) => ({ 154 | x: stat.date, 155 | y: stat.totalTimeSaved, 156 | }), 157 | ); 158 | // TODO : verify that date and total_time_saved types. 159 | 160 | return { 161 | label: tn, // Fix: use tn instead of template_name 162 | data: templateData, 163 | fill: false, 164 | borderColor: getRandomColor(), 165 | }; 166 | }), 167 | }; 168 | 169 | return ; 170 | } 171 | -------------------------------------------------------------------------------- /scripts/patch-release-for-pr.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2021 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const fs = require('fs-extra'); 19 | const path = require('path'); 20 | const semver = require('semver'); 21 | const { Octokit } = require('@octokit/rest'); 22 | const { execFile: execFileCb } = require('child_process'); 23 | const { promisify } = require('util'); 24 | 25 | const execFile = promisify(execFileCb); 26 | 27 | const owner = 'backstage'; 28 | const repo = 'backstage'; 29 | const rootDir = path.resolve(__dirname, '..'); 30 | 31 | const octokit = new Octokit({ 32 | auth: process.env.GITHUB_TOKEN, 33 | }); 34 | 35 | async function run(command, ...args) { 36 | const { stdout, stderr } = await execFile(command, args, { 37 | cwd: rootDir, 38 | }); 39 | 40 | if (stderr) { 41 | console.error(stderr); 42 | } 43 | 44 | return stdout.trim(); 45 | } 46 | 47 | /** 48 | * Finds the current stable release version of the repo, looking at 49 | * the current commit and backwards, finding the first commit were a 50 | * stable version is present. 51 | */ 52 | async function findCurrentReleaseVersion() { 53 | const rootPkgPath = path.resolve(rootDir, 'package.json'); 54 | const pkg = await fs.readJson(rootPkgPath); 55 | 56 | if (!semver.prerelease(pkg.version)) { 57 | return pkg.version; 58 | } 59 | 60 | const { stdout: revListStr } = await execFile('git', [ 61 | 'rev-list', 62 | 'HEAD', 63 | '--', 64 | 'package.json', 65 | ]); 66 | const revList = revListStr.trim().split(/\r?\n/); 67 | 68 | for (const rev of revList) { 69 | const { stdout: pkgJsonStr } = await execFile('git', [ 70 | 'show', 71 | `${rev}:package.json`, 72 | ]); 73 | if (pkgJsonStr) { 74 | const pkgJson = JSON.parse(pkgJsonStr); 75 | if (!semver.prerelease(pkgJson.version)) { 76 | return pkgJson.version; 77 | } 78 | } 79 | } 80 | 81 | throw new Error('No stable release found'); 82 | } 83 | 84 | async function main(args) { 85 | const prNumbers = args.map(s => { 86 | const num = parseInt(s, 10); 87 | if (!Number.isInteger(num)) { 88 | throw new Error(`Must provide valid PR number arguments, got ${s}`); 89 | } 90 | return num; 91 | }); 92 | console.log(`PR number(s): ${prNumbers.join(', ')}`); 93 | 94 | if (await run('git', 'status', '--porcelain')) { 95 | throw new Error('Cannot run with a dirty working tree'); 96 | } 97 | 98 | const release = await findCurrentReleaseVersion(); 99 | console.log(`Patching release ${release}`); 100 | 101 | await run('git', 'fetch'); 102 | 103 | const patchBranch = `patch/v${release}`; 104 | try { 105 | await run('git', 'checkout', `origin/${patchBranch}`); 106 | } catch { 107 | await run('git', 'checkout', '-b', patchBranch, `v${release}`); 108 | await run('git', 'push', 'origin', '-u', patchBranch); 109 | } 110 | 111 | // Create new branch, apply changes from all commits on PR branch, commit, push 112 | const branchName = `patch-release-pr-${prNumbers.join('-')}`; 113 | await run('git', 'checkout', '-b', branchName); 114 | 115 | for (const prNumber of prNumbers) { 116 | const { data } = await octokit.pulls.get({ 117 | owner, 118 | repo, 119 | pull_number: prNumber, 120 | }); 121 | 122 | const headSha = data.head.sha; 123 | if (!headSha) { 124 | throw new Error('head sha not available'); 125 | } 126 | const baseSha = data.base.sha; 127 | if (!baseSha) { 128 | throw new Error('base sha not available'); 129 | } 130 | const mergeBaseSha = await run('git', 'merge-base', headSha, baseSha); 131 | 132 | const logLines = await run( 133 | 'git', 134 | 'log', 135 | `${mergeBaseSha}...${headSha}`, 136 | '--reverse', 137 | '--pretty=%H', 138 | ); 139 | for (const logSha of logLines.split(/\r?\n/)) { 140 | await run('git', 'cherry-pick', '-n', logSha); 141 | } 142 | await run( 143 | 'git', 144 | 'commit', 145 | '--signoff', 146 | '--no-verify', 147 | '-m', 148 | `Patch from PR #${prNumber}`, 149 | ); 150 | } 151 | 152 | console.log('Running "yarn release" ...'); 153 | await run('yarn', 'release'); 154 | 155 | await run('git', 'add', '.'); 156 | await run( 157 | 'git', 158 | 'commit', 159 | '--signoff', 160 | '--no-verify', 161 | '-m', 162 | 'Generate Release', 163 | ); 164 | 165 | await run('git', 'push', 'origin', '-u', branchName); 166 | 167 | const params = new URLSearchParams({ 168 | expand: 1, 169 | body: 'This release fixes an issue where', 170 | title: `Patch release of ${prNumbers.map(nr => `#${nr}`).join(', ')}`, 171 | }); 172 | console.log( 173 | `https://github.com/backstage/backstage/compare/${patchBranch}...${branchName}?${params}`, 174 | ); 175 | } 176 | 177 | main(process.argv.slice(2)).catch(error => { 178 | console.error(error.stack || error); 179 | process.exit(1); 180 | }); 181 | -------------------------------------------------------------------------------- /scripts/build-plugins-report.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2023 The Backstage Authors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const fs = require('fs-extra'); 19 | const path = require('path'); 20 | const { execFile: execFileCb } = require('child_process'); 21 | const { promisify } = require('util'); 22 | 23 | const execFile = promisify(execFileCb); 24 | 25 | const rootDirectory = path.resolve(__dirname, '..'); 26 | const pluginsDirectory = path.resolve(rootDirectory, 'plugins'); 27 | 28 | async function run(command, ...args) { 29 | const { stdout, stderr } = await execFile(command, args, { 30 | cwd: rootDirectory, 31 | }); 32 | 33 | if (stderr) { 34 | console.error(stderr); 35 | } 36 | 37 | return stdout.trim(); 38 | } 39 | 40 | function findLatestValidCommit(commits, directoryPath) { 41 | return commits.find(commit => { 42 | const { author, message, files } = commit; 43 | 44 | // exclude merge commits 45 | if (message.startsWith('Merge pull request #')) { 46 | return false; 47 | } 48 | 49 | // exclude commits authored by a bot 50 | if (author.includes('[bot]')) { 51 | return false; 52 | } 53 | 54 | // exclude core maintainers' commits 55 | if ( 56 | [ 57 | 'ben@blam.sh', 58 | 'freben@gmail.com', 59 | 'poldsberg@gmail.com', 60 | 'johan.haals@gmail.com', 61 | ].some(email => author.includes(email)) 62 | ) { 63 | return false; 64 | } 65 | 66 | // ignore multiple plugins changes 67 | if ( 68 | files.some(file => { 69 | const fileFullPath = path.resolve(rootDirectory, file); 70 | if (!fileFullPath.startsWith(pluginsDirectory)) { 71 | return false; 72 | } 73 | const pluginBasePath = directoryPath.replace( 74 | /-(backend|common|react|node).*$/, 75 | '', 76 | ); 77 | return !fileFullPath.startsWith(pluginBasePath); 78 | }) 79 | ) { 80 | return false; 81 | } 82 | 83 | return true; 84 | }); 85 | } 86 | 87 | async function getPluginDirectory(directoryName) { 88 | const directoryPath = path.resolve(pluginsDirectory, directoryName); 89 | const packageJson = await fs.readJson( 90 | path.resolve(directoryPath, 'package.json'), 91 | ); 92 | return { directoryName, directoryPath, packageJson }; 93 | } 94 | 95 | function parseCommitsLog(log) { 96 | const lines = log.split('\n'); 97 | return lines.reduce((commits, line) => { 98 | if (!line) return commits; 99 | if (line.includes(';')) { 100 | const [author, message, date] = line.split(';'); 101 | return [...commits, { author, message, date, files: [] }]; 102 | } 103 | const { files, ...commit } = commits.pop(); 104 | return [...commits, { ...commit, files: [...files, line] }]; 105 | }, []); 106 | } 107 | 108 | async function readDirectoryCommits(directoryName) { 109 | const maxCount = 100; 110 | const directoryPath = path.resolve(pluginsDirectory, directoryName); 111 | 112 | const logOutput = await run( 113 | 'git', 114 | 'log', 115 | 'origin/master', 116 | '--name-only', 117 | `--max-count=${maxCount}`, 118 | '--pretty=format:%an <%ae>;%s;%as', 119 | '--', 120 | // ignore changes on README and package.json files 121 | path.posix.resolve(directoryPath, 'src'), 122 | `:(exclude)${path.posix.resolve(directoryPath, 'src', '**', '*.test.*')}`, 123 | ); 124 | 125 | return parseCommitsLog(logOutput); 126 | } 127 | 128 | async function getLatestDirectoryCommit({ directoryName, directoryPath }) { 129 | console.log(`🔎 Reading data for ${directoryName}`); 130 | 131 | const commits = await readDirectoryCommits(directoryName); 132 | 133 | const commit = findLatestValidCommit(commits, directoryPath) ?? { 134 | author: '-', 135 | message: '-', 136 | date: '-', 137 | }; 138 | 139 | return { plugin: directoryName, ...commit }; 140 | } 141 | 142 | function isValidDirectory({ packageJson }) { 143 | const roles = [ 144 | 'frontend-plugin', 145 | 'frontend-plugin-module', 146 | 'backend-plugin', 147 | 'backend-plugin-module', 148 | ]; 149 | return roles.includes(packageJson?.backstage?.role ?? ''); 150 | } 151 | 152 | async function main() { 153 | const directoryNames = fs 154 | .readdirSync(pluginsDirectory, { withFileTypes: true }) 155 | .filter(dirent => dirent.isDirectory()) 156 | .map(dirent => dirent.name); 157 | 158 | const directories = await Promise.all(directoryNames.map(getPluginDirectory)); 159 | 160 | const commits = await Promise.all( 161 | directories.filter(isValidDirectory).map(getLatestDirectoryCommit), 162 | ); 163 | 164 | const fileName = 'plugins-report.csv'; 165 | 166 | const fileContent = [ 167 | 'Plugin;Author;Message;Date', 168 | ...commits.map(c => `${c.plugin};${c.author};${c.message};${c.date}`), 169 | ]; 170 | 171 | console.log(`📊 Generating plugins report...`); 172 | 173 | fs.writeFile(fileName, fileContent.join('\n'), err => { 174 | if (err) throw err; 175 | }); 176 | 177 | console.log(`📄 Report generated at ${fileName}`); 178 | } 179 | 180 | main(process.argv.slice(2)).catch(error => { 181 | console.error(error.stack || error); 182 | process.exit(1); 183 | }); 184 | --------------------------------------------------------------------------------