├── 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 | 
15 | 
16 | 
17 | 
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 | 
17 | 
18 | 
19 | 
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 ` FlatRoutes>`, 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 |
--------------------------------------------------------------------------------