From 76d4fc6af1fb8df3df6e8b95d03d9c47cef6c179 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:21:16 -0800 Subject: [PATCH 01/33] chore(deps): bump @backstage/backend-common from 0.20.2 to 0.21.5 (#94) Bumps [@backstage/backend-common](https://github.com/backstage/backstage/tree/HEAD/packages/backend-common) from 0.20.2 to 0.21.5. - [Release notes](https://github.com/backstage/backstage/releases) - [Changelog](https://github.com/backstage/backstage/blob/master/packages/backend-common/CHANGELOG.md) - [Commits](https://github.com/backstage/backstage/commits/HEAD/packages/backend-common) --- updated-dependencies: - dependency-name: "@backstage/backend-common" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Asher --- packages/backend/package.json | 2 +- .../package.json | 2 +- yarn.lock | 112 ++---------------- 3 files changed, 11 insertions(+), 105 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index c8c89ec1..af232839 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,7 +16,7 @@ "build-image": "docker build ../.. -f Dockerfile --tag backstage" }, "dependencies": { - "@backstage/backend-common": "^0.20.1", + "@backstage/backend-common": "^0.21.5", "@backstage/backend-tasks": "^0.5.20", "@backstage/catalog-client": "^1.5.2", "@backstage/catalog-model": "^1.4.3", diff --git a/plugins/backstage-plugin-devcontainers-backend/package.json b/plugins/backstage-plugin-devcontainers-backend/package.json index b245f0db..6b3788cd 100644 --- a/plugins/backstage-plugin-devcontainers-backend/package.json +++ b/plugins/backstage-plugin-devcontainers-backend/package.json @@ -23,7 +23,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/backend-common": "^0.20.1", + "@backstage/backend-common": "^0.21.5", "@backstage/catalog-client": "^1.6.0", "@backstage/catalog-model": "^1.4.4", "@backstage/config": "^1.1.1", diff --git a/yarn.lock b/yarn.lock index 8a36a756..8fc6b148 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2082,47 +2082,7 @@ winston "^3.2.1" winston-transport "^4.5.0" -"@backstage/backend-app-api@^0.6.0": - version "0.6.0" - resolved "https://registry.yarnpkg.com/@backstage/backend-app-api/-/backend-app-api-0.6.0.tgz#a2700047a42cc42bca74fae0a0990c8335f74c35" - integrity sha512-637RjEG4G0an2+sY3LNpgaBq8w41buE2h0Qn1hufF02FIm3Kty2iPsMd6NmcFFrKkhF+7F2kM61VHvnzOpA+Lw== - dependencies: - "@backstage/backend-common" "^0.21.4" - "@backstage/backend-plugin-api" "^0.6.14" - "@backstage/backend-tasks" "^0.5.19" - "@backstage/cli-common" "^0.1.13" - "@backstage/cli-node" "^0.2.4" - "@backstage/config" "^1.2.0" - "@backstage/config-loader" "^1.7.0" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-node" "^0.4.9" - "@backstage/plugin-permission-node" "^0.7.25" - "@backstage/types" "^1.1.1" - "@manypkg/get-packages" "^1.1.3" - "@types/cors" "^2.8.6" - "@types/express" "^4.17.6" - compression "^1.7.4" - cookie "^0.6.0" - cors "^2.8.5" - express "^4.17.1" - express-promise-router "^4.1.0" - express-rate-limit "^7.2.0" - fs-extra "^11.2.0" - helmet "^6.0.0" - jose "^5.0.0" - lodash "^4.17.21" - logform "^2.3.2" - minimatch "^9.0.0" - minimist "^1.2.5" - morgan "^1.10.0" - node-forge "^1.3.1" - path-to-regexp "^6.2.1" - selfsigned "^2.0.0" - stoppable "^1.1.0" - winston "^3.2.1" - winston-transport "^4.5.0" - -"@backstage/backend-app-api@^0.6.1": +"@backstage/backend-app-api@^0.6.0", "@backstage/backend-app-api@^0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@backstage/backend-app-api/-/backend-app-api-0.6.1.tgz#ae113abe4d70f5c88515cba866eb1468bc86cecc" integrity sha512-B34N38JV+fAFXowGGokEvM1sUQ07hrTr1tIk986rXnQPw/MLvvqZEe9lMOgyffzAg9dxssi6wrMKFt0qX/y+3w== @@ -2320,7 +2280,7 @@ express "^4.17.1" knex "^3.0.0" -"@backstage/backend-tasks@^0.5.14", "@backstage/backend-tasks@^0.5.17", "@backstage/backend-tasks@^0.5.18", "@backstage/backend-tasks@^0.5.19", "@backstage/backend-tasks@^0.5.20": +"@backstage/backend-tasks@^0.5.14", "@backstage/backend-tasks@^0.5.17", "@backstage/backend-tasks@^0.5.18", "@backstage/backend-tasks@^0.5.20": version "0.5.20" resolved "https://registry.yarnpkg.com/@backstage/backend-tasks/-/backend-tasks-0.5.20.tgz#073419694f01bb5afd2c98f02ca4a02f31acdebe" integrity sha512-UfdSq4FP+JH2kdw5ctL3701TZLWV1I0lU1SvTkBeOKOASZ75chHR8gJNZB4HSW663OdIUdg+e07kwrOPUpBAUw== @@ -2545,29 +2505,7 @@ yn "^4.0.0" zod "^3.22.4" -"@backstage/config-loader@^1.6.1", "@backstage/config-loader@^1.6.2": - version "1.6.2" - resolved "https://registry.yarnpkg.com/@backstage/config-loader/-/config-loader-1.6.2.tgz#b3dea400ec18dc64e1f1236e450668fb5d27e221" - integrity sha512-RFFK1NGhg2n6OKRxkBPCO8qRmuRJ8gtEwjQdMv17V8AuaituOVDIduKW7omrq2RNr1CNJFodhGmpkHxqSkpkiQ== - dependencies: - "@backstage/cli-common" "^0.1.13" - "@backstage/config" "^1.1.1" - "@backstage/errors" "^1.2.3" - "@backstage/types" "^1.1.1" - "@types/json-schema" "^7.0.6" - ajv "^8.10.0" - chokidar "^3.5.2" - fs-extra "^11.2.0" - json-schema "^0.4.0" - json-schema-merge-allof "^0.8.1" - json-schema-traverse "^1.0.0" - lodash "^4.17.21" - minimist "^1.2.5" - node-fetch "^2.6.7" - typescript-json-schema "^0.63.0" - yaml "^2.0.0" - -"@backstage/config-loader@^1.7.0": +"@backstage/config-loader@^1.6.1", "@backstage/config-loader@^1.6.2", "@backstage/config-loader@^1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@backstage/config-loader/-/config-loader-1.7.0.tgz#98dee1281ef61d7933087d977f66166b1f136ac1" integrity sha512-NLZzfo3JnFsKJda99wbhY108TeGDcUAtmXE5q1ITdExHf/EZozVBFp0X/AbJOmUTAYWQgl6W6xSiUzY8Li5NIw== @@ -2831,7 +2769,7 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" -"@backstage/integration-aws-node@^0.1.11": +"@backstage/integration-aws-node@^0.1.11", "@backstage/integration-aws-node@^0.1.8", "@backstage/integration-aws-node@^0.1.9": version "0.1.11" resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.11.tgz#2900fe695badf4816c9c59e059c013f74438e7cd" integrity sha512-489G2JThdXBCEdTC1r0G4LhD8ob81iDdYLxtOIWtXabR5TRM9hIYTohEviiDEpL2GpAVbHtCIpXvvM0TMGHLBg== @@ -2844,19 +2782,6 @@ "@backstage/config" "^1.2.0" "@backstage/errors" "^1.2.4" -"@backstage/integration-aws-node@^0.1.8", "@backstage/integration-aws-node@^0.1.9": - version "0.1.9" - resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.9.tgz#66d6898e855a6a8d495d7d1bcb3bb79b6c61479c" - integrity sha512-nr3LHM9vFGtWPqWSp1lutm5+/1H6pBcMCZ2bkTn7qy/Y5Ds7l9qY+0LSMxPbIyPoaQMM2D1x/gDPEMr/pNwPAA== - dependencies: - "@aws-sdk/client-sts" "^3.350.0" - "@aws-sdk/credential-provider-node" "^3.350.0" - "@aws-sdk/credential-providers" "^3.350.0" - "@aws-sdk/types" "^3.347.0" - "@aws-sdk/util-arn-parser" "^3.310.0" - "@backstage/config" "^1.1.1" - "@backstage/errors" "^1.2.3" - "@backstage/integration-react@^1.1.23", "@backstage/integration-react@^1.1.24": version "1.1.24" resolved "https://registry.yarnpkg.com/@backstage/integration-react/-/integration-react-1.1.24.tgz#2ae41ca6ad73cf5064bbe988229f0c942ba39198" @@ -3436,7 +3361,7 @@ uuid "^9.0.0" zod "^3.22.4" -"@backstage/plugin-permission-node@^0.7.20", "@backstage/plugin-permission-node@^0.7.23", "@backstage/plugin-permission-node@^0.7.24", "@backstage/plugin-permission-node@^0.7.25", "@backstage/plugin-permission-node@^0.7.26": +"@backstage/plugin-permission-node@^0.7.20", "@backstage/plugin-permission-node@^0.7.23", "@backstage/plugin-permission-node@^0.7.24", "@backstage/plugin-permission-node@^0.7.26": version "0.7.26" resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-node/-/plugin-permission-node-0.7.26.tgz#9fc242e1fc1c81fb1fd8fe9dd85402dca71fc8fe" integrity sha512-O35/+BjH+e+XuxSuFvmwX39mnvsvpi0Y/panPqD5wnNkRB0M3D6jIe/NDWayQT0SkKUiglnpZtZX+OFcdWJXRA== @@ -8670,15 +8595,7 @@ "@types/node" "*" "@types/ssh2" "*" -"@types/dockerode@^3.3.0": - version "3.3.23" - resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.23.tgz#07b2084013d01e14d5d97856446f4d9c9f27c223" - integrity sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw== - dependencies: - "@types/docker-modem" "*" - "@types/node" "*" - -"@types/dockerode@^3.3.24": +"@types/dockerode@^3.3.0", "@types/dockerode@^3.3.24": version "3.3.26" resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.26.tgz#e7f5f06e985ee045c9b9643fd9c34684deb80cd1" integrity sha512-/K+I9bGhRO2SvyIHisGeOsy/ypxnWLz8+Rde9S2tNNEKa3r91e0XMYIEq2D+kb7srm7xrmpAR0CDKfXoZOr4OA== @@ -9077,12 +8994,11 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": - version "18.2.71" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.71.tgz#77c3b97b02014bf351b21b684f80273a3a343f96" - integrity sha512-PxEsB9OjmQeYGffoWnYAd/r5FiJuUw2niFQHPc2v2idwh8wGPkkYzOHuinNJJY6NZqfoTCiOIizDOz38gYNsyw== + version "18.2.72" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.72.tgz#3341a6d0746d1c7d8510810319323850c04bd6ed" + integrity sha512-/e7GWxGzXQF7OJAua7UAYqYi/4VpXEfbGtmYQcAQwP3SjjjAXfybTf/JK5S+SaetB/ChXl8Y2g1hCsj7jDXxcg== dependencies: "@types/prop-types" "*" - "@types/scheduler" "*" csstype "^3.0.2" "@types/request@^2.47.1", "@types/request@^2.48.8": @@ -9112,11 +9028,6 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*": - version "0.16.8" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" - integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== - "@types/semver@^7.3.12", "@types/semver@^7.5.0": version "7.5.7" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.7.tgz#326f5fdda70d13580777bcaa1bc6fa772a5aef0e" @@ -13591,11 +13502,6 @@ express-promise-router@^4.1.0: lodash.flattendeep "^4.0.0" methods "^1.0.0" -express-rate-limit@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.2.0.tgz#06ce387dd5388f429cab8263c514fc07bf90a445" - integrity sha512-T7nul1t4TNyfZMJ7pKRKkdeVJWa2CqB8NA1P8BwYaoDI5QSBZARv5oMS43J7b7I5P+4asjVXjb7ONuwDKucahg== - express-session@^1.17.1: version "1.18.0" resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.18.0.tgz#a6ae39d9091f2efba5f20fc5c65a3ce7c9ce16a3" From e81208bbdfca4124fc11556dc9950fbeb4088b28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:31:29 -0800 Subject: [PATCH 02/33] chore(deps): bump @backstage/plugin-github-actions from 0.6.10 to 0.6.14 (#99) Bumps [@backstage/plugin-github-actions](https://github.com/backstage/backstage/tree/HEAD/plugins/github-actions) from 0.6.10 to 0.6.14. - [Release notes](https://github.com/backstage/backstage/releases) - [Changelog](https://github.com/backstage/backstage/blob/master/plugins/github-actions/CHANGELOG.md) - [Commits](https://github.com/backstage/backstage/commits/HEAD/plugins/github-actions) --- updated-dependencies: - dependency-name: "@backstage/plugin-github-actions" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/app/package.json | 2 +- yarn.lock | 93 ++++++++++++++++++++++----------------- 2 files changed, 54 insertions(+), 41 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 37275716..034d2bef 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,7 +27,7 @@ "@backstage/plugin-catalog-graph": "^0.3.3", "@backstage/plugin-catalog-import": "^0.10.5", "@backstage/plugin-catalog-react": "^1.9.3", - "@backstage/plugin-github-actions": "^0.6.10", + "@backstage/plugin-github-actions": "^0.6.14", "@backstage/plugin-org": "^0.6.19", "@backstage/plugin-permission-react": "^0.4.19", "@backstage/plugin-scaffolder": "^1.17.1", diff --git a/yarn.lock b/yarn.lock index 8fc6b148..d7a98d28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2333,6 +2333,16 @@ cross-fetch "^4.0.0" uri-template "^2.0.0" +"@backstage/catalog-client@^1.6.3": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.6.3.tgz#ee09bfc685b7721b4bced2d32b53733c4c16ce48" + integrity sha512-yCgc/vi1eVnQ8cFw4+sVuRCWN69aR2LjAqaq+o4Bcq297mAC88qQOp2CdwQvFVoEGhgdfsZ/4SiGjFj+51tYrA== + dependencies: + "@backstage/catalog-model" "^1.4.5" + "@backstage/errors" "^1.2.4" + cross-fetch "^4.0.0" + uri-template "^2.0.0" + "@backstage/catalog-model@^1.4.3", "@backstage/catalog-model@^1.4.4", "@backstage/catalog-model@^1.4.5": version "1.4.5" resolved "https://registry.yarnpkg.com/@backstage/catalog-model/-/catalog-model-1.4.5.tgz#b8f6309ff12b72dffdfe852d615c553ae13452c0" @@ -2640,10 +2650,10 @@ zen-observable "^0.10.0" zod "^3.22.4" -"@backstage/core-components@^0.14.0", "@backstage/core-components@^0.14.1", "@backstage/core-components@^0.14.2": - version "0.14.2" - resolved "https://registry.yarnpkg.com/@backstage/core-components/-/core-components-0.14.2.tgz#5de725e18d8a8beaa7ab72623e88cd76f7ead30a" - integrity sha512-krOVmkNd3sSOPD8Glbql6sMXhthdj9CGSki72qtkkax8Xx9GmwJ2kGEA2yEHLzM8UVwkV+aMtlISA5AUyW6SJQ== +"@backstage/core-components@^0.14.0", "@backstage/core-components@^0.14.1", "@backstage/core-components@^0.14.2", "@backstage/core-components@^0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@backstage/core-components/-/core-components-0.14.3.tgz#3de6ebe04d31ef6dc53b127b12d76490c7bba749" + integrity sha512-2NmGRkvyxJtzPnosfus1gIhP6mqFi9UxeBbjsdpKQTemnSDs6mt52MuGrCgKPvxLAzoLIQz0R4ontQWV045nIA== dependencies: "@backstage/config" "^1.2.0" "@backstage/core-plugin-api" "^1.9.1" @@ -2769,6 +2779,21 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" +"@backstage/frontend-plugin-api@^0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@backstage/frontend-plugin-api/-/frontend-plugin-api-0.6.3.tgz#12a1909fec657eee8a90534fd0b5e3520d719bdb" + integrity sha512-AxfCcfSRp+mGdss2AEZDg84GTsJhfH1goxBpCrAzAZJa4ymvek0lYiDvTDhVVBHx01wMUoka7OWX2YlBd0MJRg== + dependencies: + "@backstage/core-components" "^0.14.3" + "@backstage/core-plugin-api" "^1.9.1" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.7" + "@material-ui/core" "^4.12.4" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + lodash "^4.17.21" + zod "^3.22.4" + zod-to-json-schema "^3.21.4" + "@backstage/integration-aws-node@^0.1.11", "@backstage/integration-aws-node@^0.1.8", "@backstage/integration-aws-node@^0.1.9": version "0.1.11" resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.11.tgz#2900fe695badf4816c9c59e059c013f74438e7cd" @@ -2782,19 +2807,7 @@ "@backstage/config" "^1.2.0" "@backstage/errors" "^1.2.4" -"@backstage/integration-react@^1.1.23", "@backstage/integration-react@^1.1.24": - version "1.1.24" - resolved "https://registry.yarnpkg.com/@backstage/integration-react/-/integration-react-1.1.24.tgz#2ae41ca6ad73cf5064bbe988229f0c942ba39198" - integrity sha512-C7aIYFCU14drZx9k0knDZeY4uq4oN5gbI4OVYJtQFVdZlgWwUuycxtw8ar9XAEzIl+UgPcpIpIWsbvOLBb8Qaw== - dependencies: - "@backstage/config" "^1.1.1" - "@backstage/core-plugin-api" "^1.9.0" - "@backstage/integration" "^1.9.0" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@types/react" "^16.13.1 || ^17.0.0" - -"@backstage/integration-react@^1.1.25": +"@backstage/integration-react@^1.1.23", "@backstage/integration-react@^1.1.24", "@backstage/integration-react@^1.1.25": version "1.1.25" resolved "https://registry.yarnpkg.com/@backstage/integration-react/-/integration-react-1.1.25.tgz#2849e063799b3c2915809ce9785253aefd4dd471" integrity sha512-WLpAD66mraSOoT2CBXFjFWxIuYAUz/sVVQUYQbnUKHtTOUjILyBcaDhwVRxYPEFjJH2AgKPwTHzxoNpstH60aw== @@ -3239,17 +3252,17 @@ "@backstage/plugin-permission-node" "^0.7.26" "@backstage/types" "^1.1.1" -"@backstage/plugin-catalog-react@^1.10.0", "@backstage/plugin-catalog-react@^1.11.1", "@backstage/plugin-catalog-react@^1.9.3": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.11.1.tgz#9419a9396682a21c447f6dbf7752e52a4e8eaa10" - integrity sha512-C1xo8TGpb2bQZ5cHU1Jbi2KSeNQj64Xw7ls7g5RQwzjEACrWot62MXPHhmVG66jztZazu3KiPeM/HCok4cJRGA== +"@backstage/plugin-catalog-react@^1.10.0", "@backstage/plugin-catalog-react@^1.11.1", "@backstage/plugin-catalog-react@^1.11.2", "@backstage/plugin-catalog-react@^1.9.3": + version "1.11.2" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.11.2.tgz#93191f382e47d3a415419e73b579327c3628148f" + integrity sha512-8n4/UrAhXoQol8rje/vaHAnYQrtlSxF2QVNenxWYLXZRr65wbWj5aa45UddkrcfaBOef7CNEDBoqRH+osHHCZQ== dependencies: - "@backstage/catalog-client" "^1.6.2" + "@backstage/catalog-client" "^1.6.3" "@backstage/catalog-model" "^1.4.5" - "@backstage/core-components" "^0.14.2" + "@backstage/core-components" "^0.14.3" "@backstage/core-plugin-api" "^1.9.1" "@backstage/errors" "^1.2.4" - "@backstage/frontend-plugin-api" "^0.6.2" + "@backstage/frontend-plugin-api" "^0.6.3" "@backstage/integration-react" "^1.1.25" "@backstage/plugin-catalog-common" "^1.0.22" "@backstage/plugin-permission-common" "^0.7.13" @@ -3309,23 +3322,23 @@ dependencies: "@backstage/backend-plugin-api" "^0.6.9" -"@backstage/plugin-github-actions@^0.6.10": - version "0.6.10" - resolved "https://registry.yarnpkg.com/@backstage/plugin-github-actions/-/plugin-github-actions-0.6.10.tgz#d64e9ba81a2138d36feb5b45209d7701492de364" - integrity sha512-l+OdaQy2m02Or+wTwV7S3i2YTntyJa+ek+8xgQbvpi1eUweRlSM10vMg9o5PtI+CR5njdkPHIMTQyWRP9nFgFw== +"@backstage/plugin-github-actions@^0.6.14": + version "0.6.14" + resolved "https://registry.yarnpkg.com/@backstage/plugin-github-actions/-/plugin-github-actions-0.6.14.tgz#ac917d6bfde4c452b9db2ef2341091aeccc789de" + integrity sha512-PSe2l+K5S/TuVBPg4GMIEjEjFk7kE5TkyqVFweUCtucdErrN3+Wmq+6zo6zXb1vA9hUOFcTueQVYiOXx50mWrA== dependencies: - "@backstage/catalog-model" "^1.4.3" - "@backstage/core-components" "^0.13.10" - "@backstage/core-plugin-api" "^1.8.2" - "@backstage/integration" "^1.8.0" - "@backstage/integration-react" "^1.1.23" - "@backstage/plugin-catalog-react" "^1.9.3" + "@backstage/catalog-model" "^1.4.5" + "@backstage/core-components" "^0.14.3" + "@backstage/core-plugin-api" "^1.9.1" + "@backstage/integration" "^1.9.1" + "@backstage/integration-react" "^1.1.25" + "@backstage/plugin-catalog-react" "^1.11.2" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.61" "@octokit/rest" "^19.0.3" - "@types/react" "^16.13.1 || ^17.0.0" - git-url-parse "^13.0.0" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + git-url-parse "^14.0.0" luxon "^3.0.0" react-use "^17.2.4" @@ -8994,9 +9007,9 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": - version "18.2.72" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.72.tgz#3341a6d0746d1c7d8510810319323850c04bd6ed" - integrity sha512-/e7GWxGzXQF7OJAua7UAYqYi/4VpXEfbGtmYQcAQwP3SjjjAXfybTf/JK5S+SaetB/ChXl8Y2g1hCsj7jDXxcg== + version "18.2.73" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.73.tgz#0579548ad122660d99e00499d22e33b81e73ed94" + integrity sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -9830,7 +9843,7 @@ anymatch@^3.0.3, anymatch@~3.1.2: "@backstage/plugin-catalog-graph" "^0.3.3" "@backstage/plugin-catalog-import" "^0.10.5" "@backstage/plugin-catalog-react" "^1.9.3" - "@backstage/plugin-github-actions" "^0.6.10" + "@backstage/plugin-github-actions" "^0.6.14" "@backstage/plugin-org" "^0.6.19" "@backstage/plugin-permission-react" "^0.4.19" "@backstage/plugin-scaffolder" "^1.17.1" From e3b9272dc3c834edc0e858b6e9939e70e282e4cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:31:53 -0800 Subject: [PATCH 03/33] chore(deps-dev): bump @backstage/e2e-test-utils from 0.1.0 to 0.1.1 (#96) Bumps [@backstage/e2e-test-utils](https://github.com/backstage/backstage/tree/HEAD/packages/e2e-test-utils) from 0.1.0 to 0.1.1. - [Release notes](https://github.com/backstage/backstage/releases) - [Changelog](https://github.com/backstage/backstage/blob/master/packages/e2e-test-utils/CHANGELOG.md) - [Commits](https://github.com/backstage/backstage/commits/v0.1.1/packages/e2e-test-utils) --- updated-dependencies: - dependency-name: "@backstage/e2e-test-utils" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index a0e2095a..20890184 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "devDependencies": { "@backstage/cli": "^0.25.1", - "@backstage/e2e-test-utils": "^0.1.0", + "@backstage/e2e-test-utils": "^0.1.1", "@playwright/test": "^1.32.3", "@spotify/prettier-config": "^15.0.0", "concurrently": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index d7a98d28..9daa41b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2725,13 +2725,13 @@ "@types/react" "^16.13.1 || ^17.0.0" react-use "^17.2.4" -"@backstage/e2e-test-utils@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@backstage/e2e-test-utils/-/e2e-test-utils-0.1.0.tgz#6be33f581b4493d568e9fd7674a9a5804f354482" - integrity sha512-GCsIexoqPpkawvy7fmuzlscKMyOwLEgFuLBYwULIrQsmUAmk0kVccWZTa4l/isoIvXg7L0TSo9kjR95rDsNGEg== +"@backstage/e2e-test-utils@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/e2e-test-utils/-/e2e-test-utils-0.1.1.tgz#309164a5006593da711f55f430dc4d11a3036514" + integrity sha512-g5knWOQT/vEv3RwMtN3sCz7m43r+jFoO35y7FBuruhGJfzJY4Q4qK+wSGwJ/hSZ7Uj6WBNRsyw8oauMPKiRA5A== dependencies: "@manypkg/get-packages" "^1.1.3" - fs-extra "^10.1.0" + fs-extra "^11.0.0" "@backstage/errors@^1.2.3", "@backstage/errors@^1.2.4": version "1.2.4" @@ -14027,7 +14027,7 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@10.1.0, fs-extra@^10.0.0, fs-extra@^10.1.0: +fs-extra@10.1.0, fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== From 2bf76cb3e84378cf97ca418e972d1c3cdb8bb1d3 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 27 Mar 2024 10:42:41 -0800 Subject: [PATCH 04/33] Skip plugin step if no plugins I thought it would just skip if the matrix was empty but it errors instead. --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 41a09d65..a25b3151 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,6 +40,7 @@ jobs: - "plugins/backstage-plugin-devcontainers-react/**" plugin: needs: changes + if: ${{ needs.changes.outputs.plugins != '' && toJson(fromJson(needs.changes.outputs.plugins)) != '[]' }} runs-on: ubuntu-latest strategy: matrix: From b09f951a988d7cd588bf9c6cee6ad93028b87569 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 27 Mar 2024 10:43:58 -0800 Subject: [PATCH 05/33] Fix devcontainers-react not running on workflow change --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a25b3151..b049e575 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,7 +36,7 @@ jobs: - ".github/workflows/test.yaml" - "plugins/backstage-plugin-devcontainers-backend/**" devcontainers-react: - - ".github/workflows/build.yaml" + - ".github/workflows/test.yaml" - "plugins/backstage-plugin-devcontainers-react/**" plugin: needs: changes From c34f00cab4b8023ce00ae5c09f3de4fc89ffaf05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:49:31 -0800 Subject: [PATCH 06/33] chore(deps): bump @backstage/plugin-search-backend-module-techdocs (#98) Bumps [@backstage/plugin-search-backend-module-techdocs](https://github.com/backstage/backstage/tree/HEAD/plugins/search-backend-module-techdocs) from 0.1.17 to 0.1.21. - [Release notes](https://github.com/backstage/backstage/releases) - [Changelog](https://github.com/backstage/backstage/blob/master/plugins/search-backend-module-techdocs/CHANGELOG.md) - [Commits](https://github.com/backstage/backstage/commits/HEAD/plugins/search-backend-module-techdocs) --- updated-dependencies: - dependency-name: "@backstage/plugin-search-backend-module-techdocs" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/backend/package.json | 2 +- yarn.lock | 230 +++++++++++++++++++++++----------- 2 files changed, 157 insertions(+), 75 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index af232839..7ec5ae1f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -34,7 +34,7 @@ "@backstage/plugin-search-backend": "^1.4.9", "@backstage/plugin-search-backend-module-catalog": "^0.1.13", "@backstage/plugin-search-backend-module-pg": "^0.5.24", - "@backstage/plugin-search-backend-module-techdocs": "^0.1.13", + "@backstage/plugin-search-backend-module-techdocs": "^0.1.21", "@backstage/plugin-search-backend-node": "^1.2.13", "@backstage/plugin-techdocs-backend": "^1.9.6", "@coder/backstage-plugin-devcontainers-backend": "0.0.0", diff --git a/yarn.lock b/yarn.lock index 9daa41b9..bcd0911f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2082,7 +2082,7 @@ winston "^3.2.1" winston-transport "^4.5.0" -"@backstage/backend-app-api@^0.6.0", "@backstage/backend-app-api@^0.6.1": +"@backstage/backend-app-api@^0.6.0": version "0.6.1" resolved "https://registry.yarnpkg.com/@backstage/backend-app-api/-/backend-app-api-0.6.1.tgz#ae113abe4d70f5c88515cba866eb1468bc86cecc" integrity sha512-B34N38JV+fAFXowGGokEvM1sUQ07hrTr1tIk986rXnQPw/MLvvqZEe9lMOgyffzAg9dxssi6wrMKFt0qX/y+3w== @@ -2121,6 +2121,45 @@ winston "^3.2.1" winston-transport "^4.5.0" +"@backstage/backend-app-api@^0.6.2": + version "0.6.2" + resolved "https://registry.yarnpkg.com/@backstage/backend-app-api/-/backend-app-api-0.6.2.tgz#69259601d6b0bd909486f48c093db2b3ece0ae07" + integrity sha512-Eo6nIuuBYudXqRvBVO5D0ujsLk8FH46eF+Jx+U+7d4S8gj9lbw7Tst2wkNdTaOEoQwYGO0UNO8TZUMItwCOBAQ== + dependencies: + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/backend-tasks" "^0.5.21" + "@backstage/cli-common" "^0.1.13" + "@backstage/cli-node" "^0.2.4" + "@backstage/config" "^1.2.0" + "@backstage/config-loader" "^1.7.0" + "@backstage/errors" "^1.2.4" + "@backstage/plugin-auth-node" "^0.4.11" + "@backstage/plugin-permission-node" "^0.7.27" + "@backstage/types" "^1.1.1" + "@manypkg/get-packages" "^1.1.3" + "@types/cors" "^2.8.6" + "@types/express" "^4.17.6" + compression "^1.7.4" + cookie "^0.6.0" + cors "^2.8.5" + express "^4.17.1" + express-promise-router "^4.1.0" + fs-extra "^11.2.0" + helmet "^6.0.0" + jose "^5.0.0" + lodash "^4.17.21" + logform "^2.3.2" + minimatch "^9.0.0" + minimist "^1.2.5" + morgan "^1.10.0" + node-forge "^1.3.1" + path-to-regexp "^6.2.1" + selfsigned "^2.0.0" + stoppable "^1.1.0" + winston "^3.2.1" + winston-transport "^4.5.0" + "@backstage/backend-common@^0.20.1": version "0.20.2" resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.20.2.tgz#0ce5b7bfcb91918008c4ec6bb6aede72c4474e20" @@ -2182,25 +2221,25 @@ yauzl "^2.10.0" yn "^4.0.0" -"@backstage/backend-common@^0.21.2", "@backstage/backend-common@^0.21.3", "@backstage/backend-common@^0.21.4", "@backstage/backend-common@^0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.21.5.tgz#dcc8c50c267365953e9cef101537388d586030d4" - integrity sha512-moksOfYww8L+vY4kVsQSSwtpWq+lBlqC7A1Lcn1jMo96oKQvRNtYAa5PqPS4ziejwCIB9eCA9eDlweuLCfT1CQ== +"@backstage/backend-common@^0.21.2", "@backstage/backend-common@^0.21.3", "@backstage/backend-common@^0.21.4", "@backstage/backend-common@^0.21.5", "@backstage/backend-common@^0.21.6": + version "0.21.6" + resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.21.6.tgz#e33c744d130839c2e4c596ffb881995dd0fdc489" + integrity sha512-JRLBBz3S9h7yCqOs06/rV4qR/lwOmHvIVRP5fEqhhHXQA8jw9kqetIo7SxVDIQwopCjFTLUydpXtPpDcFYdLOA== dependencies: "@aws-sdk/abort-controller" "^3.347.0" "@aws-sdk/client-s3" "^3.350.0" "@aws-sdk/credential-providers" "^3.350.0" "@aws-sdk/types" "^3.347.0" - "@backstage/backend-app-api" "^0.6.1" + "@backstage/backend-app-api" "^0.6.2" "@backstage/backend-dev-utils" "^0.1.4" - "@backstage/backend-plugin-api" "^0.6.15" + "@backstage/backend-plugin-api" "^0.6.16" "@backstage/cli-common" "^0.1.13" "@backstage/config" "^1.2.0" "@backstage/config-loader" "^1.7.0" "@backstage/errors" "^1.2.4" "@backstage/integration" "^1.9.1" - "@backstage/integration-aws-node" "^0.1.11" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/integration-aws-node" "^0.1.12" + "@backstage/plugin-auth-node" "^0.4.11" "@backstage/types" "^1.1.1" "@google-cloud/storage" "^7.0.0" "@keyv/memcache" "^1.3.5" @@ -2266,26 +2305,26 @@ openapi-merge "^1.3.2" openapi3-ts "^3.1.2" -"@backstage/backend-plugin-api@^0.6.12", "@backstage/backend-plugin-api@^0.6.13", "@backstage/backend-plugin-api@^0.6.14", "@backstage/backend-plugin-api@^0.6.15", "@backstage/backend-plugin-api@^0.6.9": - version "0.6.15" - resolved "https://registry.yarnpkg.com/@backstage/backend-plugin-api/-/backend-plugin-api-0.6.15.tgz#1eb556e92ddc730f4907956cabf4ef99f6e06bdd" - integrity sha512-vBMEBNiuJk/71/A2LGKCx3E95OS8yJMawBExD588HdFinbmzDmg37oD5KY8BETlEBgMC5xQ3LdlbR491wzMT7w== +"@backstage/backend-plugin-api@^0.6.12", "@backstage/backend-plugin-api@^0.6.13", "@backstage/backend-plugin-api@^0.6.14", "@backstage/backend-plugin-api@^0.6.15", "@backstage/backend-plugin-api@^0.6.16", "@backstage/backend-plugin-api@^0.6.9": + version "0.6.16" + resolved "https://registry.yarnpkg.com/@backstage/backend-plugin-api/-/backend-plugin-api-0.6.16.tgz#4597ead4bea7aa70bca1633d9f5fefedb818832a" + integrity sha512-CUYH9MOkOKtFByA33aw5IeeaCswd9W6EfWFTrGWDbJ1LCA5L0pyUaIySp/q9ziwxkRkBMDU3nGlnz7sjN7L4sg== dependencies: - "@backstage/backend-tasks" "^0.5.20" + "@backstage/backend-tasks" "^0.5.21" "@backstage/config" "^1.2.0" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/plugin-auth-node" "^0.4.11" "@backstage/plugin-permission-common" "^0.7.13" "@backstage/types" "^1.1.1" "@types/express" "^4.17.6" express "^4.17.1" knex "^3.0.0" -"@backstage/backend-tasks@^0.5.14", "@backstage/backend-tasks@^0.5.17", "@backstage/backend-tasks@^0.5.18", "@backstage/backend-tasks@^0.5.20": - version "0.5.20" - resolved "https://registry.yarnpkg.com/@backstage/backend-tasks/-/backend-tasks-0.5.20.tgz#073419694f01bb5afd2c98f02ca4a02f31acdebe" - integrity sha512-UfdSq4FP+JH2kdw5ctL3701TZLWV1I0lU1SvTkBeOKOASZ75chHR8gJNZB4HSW663OdIUdg+e07kwrOPUpBAUw== +"@backstage/backend-tasks@^0.5.14", "@backstage/backend-tasks@^0.5.17", "@backstage/backend-tasks@^0.5.20", "@backstage/backend-tasks@^0.5.21": + version "0.5.21" + resolved "https://registry.yarnpkg.com/@backstage/backend-tasks/-/backend-tasks-0.5.21.tgz#5f8c76f903bfd782f7c9099a19b8ca34f540f8ca" + integrity sha512-zZt5GVE9dRwgjSWdssUZHx+jTKXXLqUZgFB1RKeCIzJFjIny+b9NfvGng9z0l9iYY1d7Iablvuz3DtMQCE/yAA== dependencies: - "@backstage/backend-common" "^0.21.5" + "@backstage/backend-common" "^0.21.6" "@backstage/config" "^1.2.0" "@backstage/errors" "^1.2.4" "@backstage/types" "^1.1.1" @@ -2323,17 +2362,7 @@ textextensions "^5.16.0" uuid "^9.0.0" -"@backstage/catalog-client@^1.5.2", "@backstage/catalog-client@^1.6.0", "@backstage/catalog-client@^1.6.2": - version "1.6.2" - resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.6.2.tgz#862db5b1b07f77acd13039ca56b83650cd8362d6" - integrity sha512-26ZN+NmBpjxJgzd1dlO6JT497U9eMrsFORKNE7EvbUYXXJh4zVZSy+NaA1hNqNtbTzmEvvc8IAKQRG7x5UmnVw== - dependencies: - "@backstage/catalog-model" "^1.4.5" - "@backstage/errors" "^1.2.4" - cross-fetch "^4.0.0" - uri-template "^2.0.0" - -"@backstage/catalog-client@^1.6.3": +"@backstage/catalog-client@^1.5.2", "@backstage/catalog-client@^1.6.0", "@backstage/catalog-client@^1.6.2", "@backstage/catalog-client@^1.6.3": version "1.6.3" resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.6.3.tgz#ee09bfc685b7721b4bced2d32b53733c4c16ce48" integrity sha512-yCgc/vi1eVnQ8cFw4+sVuRCWN69aR2LjAqaq+o4Bcq297mAC88qQOp2CdwQvFVoEGhgdfsZ/4SiGjFj+51tYrA== @@ -2794,7 +2823,20 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" -"@backstage/integration-aws-node@^0.1.11", "@backstage/integration-aws-node@^0.1.8", "@backstage/integration-aws-node@^0.1.9": +"@backstage/integration-aws-node@^0.1.12": + version "0.1.12" + resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.12.tgz#d2c5ac7c81cd6c2733dcfd24544ad21931ea815d" + integrity sha512-bPOBM1a/v3Oo4svOKjQbjvBmaKDqCGfSLBtH2rrp1dj1Mk8Pr+hmvQYQZBHqfc0gTqddRST3gz6GGL2ZKovWUw== + dependencies: + "@aws-sdk/client-sts" "^3.350.0" + "@aws-sdk/credential-provider-node" "^3.350.0" + "@aws-sdk/credential-providers" "^3.350.0" + "@aws-sdk/types" "^3.347.0" + "@aws-sdk/util-arn-parser" "^3.310.0" + "@backstage/config" "^1.2.0" + "@backstage/errors" "^1.2.4" + +"@backstage/integration-aws-node@^0.1.8": version "0.1.11" resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.11.tgz#2900fe695badf4816c9c59e059c013f74438e7cd" integrity sha512-489G2JThdXBCEdTC1r0G4LhD8ob81iDdYLxtOIWtXabR5TRM9hIYTohEviiDEpL2GpAVbHtCIpXvvM0TMGHLBg== @@ -3099,6 +3141,29 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" +"@backstage/plugin-auth-node@^0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-node/-/plugin-auth-node-0.4.11.tgz#81747130b8d88a8526136a78d5dcad0629497d1d" + integrity sha512-85FmLUUChHu+t4HZrejKuOTZtR4nQnslJ1nx1quypT+7oRGO5/Xla6vTqM1EQw79umxA/sy5lcrbOWQHODFNpg== + dependencies: + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/catalog-client" "^1.6.3" + "@backstage/catalog-model" "^1.4.5" + "@backstage/config" "^1.2.0" + "@backstage/errors" "^1.2.4" + "@backstage/types" "^1.1.1" + "@types/express" "*" + "@types/passport" "^1.0.3" + express "^4.17.1" + jose "^5.0.0" + lodash "^4.17.21" + node-fetch "^2.6.7" + passport "^0.7.0" + winston "^3.2.1" + zod "^3.22.4" + zod-to-json-schema "^3.21.4" + "@backstage/plugin-catalog-backend-module-github@^0.4.7": version "0.4.7" resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-backend-module-github/-/plugin-catalog-backend-module-github-0.4.7.tgz#5123eb8a6f491d925d2d0ef399318412ab012a96" @@ -3238,18 +3303,18 @@ react-use "^17.2.4" yaml "^2.0.0" -"@backstage/plugin-catalog-node@^1.10.0", "@backstage/plugin-catalog-node@^1.6.1", "@backstage/plugin-catalog-node@^1.7.2", "@backstage/plugin-catalog-node@^1.7.3": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-node/-/plugin-catalog-node-1.10.0.tgz#ae6715aa8c08ff68ffa1e62ff006594b2621449c" - integrity sha512-cMtmyITn3rtdCHRwyFuVX1oBp2np5iif5SafTQqH1E0uTekbmpqls4zduklrbmR9/aIjHR4y9LtWo109EEQs8g== +"@backstage/plugin-catalog-node@^1.10.0", "@backstage/plugin-catalog-node@^1.11.0", "@backstage/plugin-catalog-node@^1.6.1", "@backstage/plugin-catalog-node@^1.7.2": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-node/-/plugin-catalog-node-1.11.0.tgz#02c458f18da1fda9e4863e0ed450a6560c7401ea" + integrity sha512-w2uTngL2fVeXw24Iag24qJhShw8XdS0ijyovuo/xLwjED+xdP+MWXql6mhQnviic29NXnHChGdiVngP3ka8Mlg== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/catalog-client" "^1.6.2" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/catalog-client" "^1.6.3" "@backstage/catalog-model" "^1.4.5" "@backstage/errors" "^1.2.4" "@backstage/plugin-catalog-common" "^1.0.22" "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-permission-node" "^0.7.26" + "@backstage/plugin-permission-node" "^0.7.27" "@backstage/types" "^1.1.1" "@backstage/plugin-catalog-react@^1.10.0", "@backstage/plugin-catalog-react@^1.11.1", "@backstage/plugin-catalog-react@^1.11.2", "@backstage/plugin-catalog-react@^1.9.3": @@ -3391,6 +3456,23 @@ zod "^3.22.4" zod-to-json-schema "^3.20.4" +"@backstage/plugin-permission-node@^0.7.27": + version "0.7.27" + resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-node/-/plugin-permission-node-0.7.27.tgz#e29f8ec70fdc57af1a813038b8b536d48a621792" + integrity sha512-ExNF2NbbVH1BdrtNMlf5DNKjzgsRlABeP4cMHPJeBdSgZs3tYMNkBDRMf5Z/kaHSYRojNFHJWNHyFfKZqRiDxA== + dependencies: + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/config" "^1.2.0" + "@backstage/errors" "^1.2.4" + "@backstage/plugin-auth-node" "^0.4.11" + "@backstage/plugin-permission-common" "^0.7.13" + "@types/express" "^4.17.6" + express "^4.17.1" + express-promise-router "^4.1.0" + zod "^3.22.4" + zod-to-json-schema "^3.20.4" + "@backstage/plugin-permission-react@^0.4.19", "@backstage/plugin-permission-react@^0.4.20", "@backstage/plugin-permission-react@^0.4.21": version "0.4.21" resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-react/-/plugin-permission-react-0.4.21.tgz#bbdc098fa8eee7d99093b811884528aefc1b1d2c" @@ -3740,36 +3822,36 @@ uuid "^9.0.0" winston "^3.2.1" -"@backstage/plugin-search-backend-module-techdocs@^0.1.13", "@backstage/plugin-search-backend-module-techdocs@^0.1.17": - version "0.1.17" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-module-techdocs/-/plugin-search-backend-module-techdocs-0.1.17.tgz#92c8b356e04c4e6891e7e5c11322c743c300c8a0" - integrity sha512-0O1qJM6RsZn9z7MxAw5rtVqS2Huxitl8+vgPRr4x7S415q1jUEaSx9MzcgTU6lyMby8sdxwDsvSU76FCnlh4yA== +"@backstage/plugin-search-backend-module-techdocs@^0.1.17", "@backstage/plugin-search-backend-module-techdocs@^0.1.21": + version "0.1.21" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-module-techdocs/-/plugin-search-backend-module-techdocs-0.1.21.tgz#20818095fa418bea3370095804f66459102055fb" + integrity sha512-8L2bUOtt89SmsftxRHpJ6XA0SBdwhUHNSDhUk53YgccWxta8fDg27ZzeROsF/xI6DZTWe0lUODflEPA634tGhw== dependencies: - "@backstage/backend-common" "^0.21.3" - "@backstage/backend-plugin-api" "^0.6.13" - "@backstage/backend-tasks" "^0.5.18" - "@backstage/catalog-client" "^1.6.0" - "@backstage/catalog-model" "^1.4.4" - "@backstage/config" "^1.1.1" - "@backstage/plugin-catalog-common" "^1.0.21" - "@backstage/plugin-catalog-node" "^1.7.3" - "@backstage/plugin-permission-common" "^0.7.12" - "@backstage/plugin-search-backend-node" "^1.2.17" - "@backstage/plugin-search-common" "^1.2.10" - "@backstage/plugin-techdocs-node" "^1.11.5" + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/backend-tasks" "^0.5.21" + "@backstage/catalog-client" "^1.6.3" + "@backstage/catalog-model" "^1.4.5" + "@backstage/config" "^1.2.0" + "@backstage/plugin-catalog-common" "^1.0.22" + "@backstage/plugin-catalog-node" "^1.11.0" + "@backstage/plugin-permission-common" "^0.7.13" + "@backstage/plugin-search-backend-node" "^1.2.20" + "@backstage/plugin-search-common" "^1.2.11" + "@backstage/plugin-techdocs-node" "^1.12.2" lodash "^4.17.21" node-fetch "^2.6.7" p-limit "^3.1.0" winston "^3.2.1" -"@backstage/plugin-search-backend-node@^1.2.13", "@backstage/plugin-search-backend-node@^1.2.17", "@backstage/plugin-search-backend-node@^1.2.19": - version "1.2.19" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-node/-/plugin-search-backend-node-1.2.19.tgz#0d10acd6d40a7308ca4ab44b42d02ec6b8ff955f" - integrity sha512-osbSILue84z7R/HBFOFYB4buOv/Jmk141o/GANWbxFfZjtyGaOe3obfStrqIXrn2irJ32TrU7IO99NyFCEPBeg== +"@backstage/plugin-search-backend-node@^1.2.13", "@backstage/plugin-search-backend-node@^1.2.19", "@backstage/plugin-search-backend-node@^1.2.20": + version "1.2.20" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-node/-/plugin-search-backend-node-1.2.20.tgz#90c6fb2c11499a13fed11ef4ec766e85efd8aa2d" + integrity sha512-hrLMT+G5q9OS/it++GBvnF5RGy7V8R/3d4sc/cWWvpQsT01CqFxCfz8lIGm9OTi7btmJ1xNiwlefMtTJ/NS2pg== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/backend-tasks" "^0.5.20" + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/backend-tasks" "^0.5.21" "@backstage/config" "^1.2.0" "@backstage/errors" "^1.2.4" "@backstage/plugin-permission-common" "^0.7.13" @@ -3934,10 +4016,10 @@ git-url-parse "^14.0.0" photoswipe "^5.3.7" -"@backstage/plugin-techdocs-node@^1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-node/-/plugin-techdocs-node-1.11.5.tgz#8ae50163e0bbc95fc9e3e80876ad81eb78a2efb1" - integrity sha512-FPlUM4YE3jnUu2o0YH3wd8zztgnNmwGVUBuLwV84u63EcBbFfajFpWiiWcjmwYAqD5d10RyXdn9ClPcl0BKWcQ== +"@backstage/plugin-techdocs-node@^1.11.5", "@backstage/plugin-techdocs-node@^1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-node/-/plugin-techdocs-node-1.12.2.tgz#c29b3662d07a2502c2d0b97db9180de6cf0edfc6" + integrity sha512-Q5MyD40K8N7uRxmZdjsXzremMtY+KeR+Xh+u0ZPeq3r01LUG4d70S/kSnIYnuuXHtpvhpJ2ZO1OAQSgyREV9VQ== dependencies: "@aws-sdk/client-s3" "^3.350.0" "@aws-sdk/credential-providers" "^3.350.0" @@ -3945,14 +4027,14 @@ "@aws-sdk/types" "^3.347.0" "@azure/identity" "^4.0.0" "@azure/storage-blob" "^12.5.0" - "@backstage/backend-common" "^0.21.3" - "@backstage/backend-plugin-api" "^0.6.13" - "@backstage/catalog-model" "^1.4.4" - "@backstage/config" "^1.1.1" - "@backstage/errors" "^1.2.3" - "@backstage/integration" "^1.9.0" - "@backstage/integration-aws-node" "^0.1.9" - "@backstage/plugin-search-common" "^1.2.10" + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/catalog-model" "^1.4.5" + "@backstage/config" "^1.2.0" + "@backstage/errors" "^1.2.4" + "@backstage/integration" "^1.9.1" + "@backstage/integration-aws-node" "^0.1.12" + "@backstage/plugin-search-common" "^1.2.11" "@google-cloud/storage" "^7.0.0" "@smithy/node-http-handler" "^2.1.7" "@trendyol-js/openstack-swift-sdk" "^0.0.7" From ae8e71f9430766cf30f8869ab30c4fee58332e7c Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 27 Mar 2024 12:34:00 -0800 Subject: [PATCH 07/33] Disable Dependabot Something is not quite right. We keep getting build failures that did not appear in the upgrade PR. Maybe something weird with when a dependency used only in the app is updated, since we only run the tests when there are changes to individual plugins. --- .github/dependabot.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 922ee7d6..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 -updates: - - package-ecosystem: 'github-actions' - directory: '/' - schedule: - interval: 'daily' - - - package-ecosystem: 'npm' - directory: '/' - schedule: - interval: 'daily' - versioning-strategy: increase From 8f195c5e617d5074dd35234d90a0efab163c8078 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 28 Mar 2024 10:13:42 -0800 Subject: [PATCH 08/33] Fix dependency issues (#100) * Revert dependencies to pre-Dependabot This reverts all dependencies to how they were scaffolded, before Dependabot started making updates. * Test all plugins when yarn.lock file changes Since the plugins, app, and backend share a lockfile, it seems we can get some odd issues when we update dependencies that are not caught until the next time a plugin is tested. In particular, this happened with Dependabot, and although we were updating the package.json as well to make sure affected plugins were tested I think the issue comes with updating dependencies of dependencies not directly listed in the package.json. * Remove backend-test-utils from devcontainers-backend --- .github/workflows/test.yaml | 3 + package.json | 4 +- packages/app/package.json | 24 +- packages/backend/package.json | 20 +- plugins/backstage-plugin-coder/package.json | 4 +- .../package.json | 5 +- .../package.json | 6 +- yarn.lock | 2037 +++++++---------- 8 files changed, 825 insertions(+), 1278 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b049e575..4fc7ce92 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,12 +31,15 @@ jobs: filters: | coder: - ".github/workflows/test.yaml" + - "yarn.lock" - "plugins/backstage-plugin-coder/**" devcontainers-backend: - ".github/workflows/test.yaml" + - "yarn.lock" - "plugins/backstage-plugin-devcontainers-backend/**" devcontainers-react: - ".github/workflows/test.yaml" + - "yarn.lock" - "plugins/backstage-plugin-devcontainers-react/**" plugin: needs: changes diff --git a/package.json b/package.json index 20890184..c6b0c89b 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ }, "devDependencies": { "@backstage/cli": "^0.25.1", - "@backstage/e2e-test-utils": "^0.1.1", + "@backstage/e2e-test-utils": "^0.1.0", "@playwright/test": "^1.32.3", - "@spotify/prettier-config": "^15.0.0", + "@spotify/prettier-config": "^12.0.0", "concurrently": "^8.0.0", "lerna": "^7.3.0", "node-gyp": "^9.0.0", diff --git a/packages/app/package.json b/packages/app/package.json index 034d2bef..88ecc88a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,30 +14,30 @@ "lint": "backstage-cli package lint" }, "dependencies": { - "@backstage/app-defaults": "^1.5.0", + "@backstage/app-defaults": "^1.4.7", "@backstage/catalog-model": "^1.4.3", "@backstage/cli": "^0.25.1", "@backstage/core-app-api": "^1.11.3", - "@backstage/core-components": "^0.14.0", + "@backstage/core-components": "^0.13.10", "@backstage/core-plugin-api": "^1.8.2", "@backstage/integration-react": "^1.1.23", - "@backstage/plugin-api-docs": "^0.11.2", + "@backstage/plugin-api-docs": "^0.10.3", "@backstage/plugin-catalog": "^1.16.1", "@backstage/plugin-catalog-common": "^1.0.20", "@backstage/plugin-catalog-graph": "^0.3.3", "@backstage/plugin-catalog-import": "^0.10.5", "@backstage/plugin-catalog-react": "^1.9.3", - "@backstage/plugin-github-actions": "^0.6.14", + "@backstage/plugin-github-actions": "^0.6.10", "@backstage/plugin-org": "^0.6.19", "@backstage/plugin-permission-react": "^0.4.19", "@backstage/plugin-scaffolder": "^1.17.1", - "@backstage/plugin-search": "^1.4.6", + "@backstage/plugin-search": "^1.4.5", "@backstage/plugin-search-react": "^1.7.5", - "@backstage/plugin-tech-radar": "^0.7.0", + "@backstage/plugin-tech-radar": "^0.6.12", "@backstage/plugin-techdocs": "^1.9.3", - "@backstage/plugin-techdocs-module-addons-contrib": "^1.1.5", + "@backstage/plugin-techdocs-module-addons-contrib": "^1.1.4", "@backstage/plugin-techdocs-react": "^1.1.15", - "@backstage/plugin-user-settings": "^0.8.1", + "@backstage/plugin-user-settings": "^0.8.0", "@backstage/theme": "^0.5.0", "@coder/backstage-plugin-coder": "0.0.0", "@coder/backstage-plugin-devcontainers-react": "0.0.0", @@ -46,16 +46,16 @@ "history": "^5.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", - "react-router": "^6.22.3", - "react-router-dom": "^6.22.3", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", "react-use": "^17.2.4" }, "devDependencies": { "@backstage/test-utils": "^1.4.7", "@playwright/test": "^1.32.3", "@testing-library/dom": "^9.0.0", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", "@types/react-dom": "*", "cross-env": "^7.0.0" diff --git a/packages/backend/package.json b/packages/backend/package.json index 7ec5ae1f..c021564d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,32 +16,32 @@ "build-image": "docker build ../.. -f Dockerfile --tag backstage" }, "dependencies": { - "@backstage/backend-common": "^0.21.5", - "@backstage/backend-tasks": "^0.5.20", + "@backstage/backend-common": "^0.20.1", + "@backstage/backend-tasks": "^0.5.14", "@backstage/catalog-client": "^1.5.2", "@backstage/catalog-model": "^1.4.3", "@backstage/config": "^1.1.1", "@backstage/plugin-app-backend": "^0.3.57", - "@backstage/plugin-auth-backend": "^0.22.2", + "@backstage/plugin-auth-backend": "^0.20.3", "@backstage/plugin-auth-node": "^0.4.3", "@backstage/plugin-catalog-backend": "^1.16.1", "@backstage/plugin-catalog-backend-module-github": "^0.4.7", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.1.6", "@backstage/plugin-permission-common": "^0.7.12", - "@backstage/plugin-permission-node": "^0.7.24", + "@backstage/plugin-permission-node": "^0.7.20", "@backstage/plugin-proxy-backend": "^0.4.7", - "@backstage/plugin-scaffolder-backend": "^1.22.2", + "@backstage/plugin-scaffolder-backend": "^1.20.0", "@backstage/plugin-search-backend": "^1.4.9", "@backstage/plugin-search-backend-module-catalog": "^0.1.13", - "@backstage/plugin-search-backend-module-pg": "^0.5.24", - "@backstage/plugin-search-backend-module-techdocs": "^0.1.21", + "@backstage/plugin-search-backend-module-pg": "^0.5.18", + "@backstage/plugin-search-backend-module-techdocs": "^0.1.13", "@backstage/plugin-search-backend-node": "^1.2.13", - "@backstage/plugin-techdocs-backend": "^1.9.6", + "@backstage/plugin-techdocs-backend": "^1.9.2", "@coder/backstage-plugin-devcontainers-backend": "0.0.0", "app": "link:../app", "better-sqlite3": "^9.0.0", "dockerode": "^3.3.1", - "express": "^4.19.2", + "express": "^4.17.1", "express-promise-router": "^4.1.0", "node-gyp": "^9.0.0", "pg": "^8.11.3", @@ -52,7 +52,7 @@ "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", - "@types/luxon": "^3.4.2" + "@types/luxon": "^2.0.4" }, "files": [ "dist" diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 19686173..b65a89a7 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -32,7 +32,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/core-components": "^0.14.0", + "@backstage/core-components": "^0.13.10", "@backstage/core-plugin-api": "^1.8.2", "@backstage/integration-react": "^1.1.24", "@backstage/plugin-catalog-react": "^1.10.0", @@ -52,7 +52,7 @@ "@backstage/core-app-api": "^1.11.3", "@backstage/dev-utils": "^1.0.26", "@backstage/test-utils": "^1.4.7", - "@testing-library/jest-dom": "^6.4.2", + "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.0.0", "msw": "^1.0.0" diff --git a/plugins/backstage-plugin-devcontainers-backend/package.json b/plugins/backstage-plugin-devcontainers-backend/package.json index 6b3788cd..57867586 100644 --- a/plugins/backstage-plugin-devcontainers-backend/package.json +++ b/plugins/backstage-plugin-devcontainers-backend/package.json @@ -23,7 +23,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/backend-common": "^0.21.5", + "@backstage/backend-common": "^0.20.1", "@backstage/catalog-client": "^1.6.0", "@backstage/catalog-model": "^1.4.4", "@backstage/config": "^1.1.1", @@ -31,13 +31,12 @@ "@backstage/plugin-catalog-common": "^1.0.21", "@backstage/plugin-catalog-node": "^1.7.2", "@types/express": "*", - "express": "^4.19.2", + "express": "^4.17.1", "express-promise-router": "^4.1.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.3.4", "@backstage/cli": "^0.25.1", "@types/supertest": "^2.0.12", "msw": "^1.0.0", diff --git a/plugins/backstage-plugin-devcontainers-react/package.json b/plugins/backstage-plugin-devcontainers-react/package.json index f6cad821..16407682 100644 --- a/plugins/backstage-plugin-devcontainers-react/package.json +++ b/plugins/backstage-plugin-devcontainers-react/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@backstage/catalog-model": "^1.4.4", - "@backstage/core-components": "^0.14.0", + "@backstage/core-components": "^0.13.10", "@backstage/core-plugin-api": "^1.8.2", "@backstage/plugin-catalog-react": "^1.10.0", "@backstage/theme": "^0.5.0", @@ -42,8 +42,8 @@ "@backstage/core-app-api": "^1.11.3", "@backstage/dev-utils": "^1.0.26", "@backstage/test-utils": "^1.4.7", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", "msw": "^1.0.0" }, diff --git a/yarn.lock b/yarn.lock index bcd0911f..a60186cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,7 +7,7 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@adobe/css-tools@^4.3.2": +"@adobe/css-tools@^4.0.1", "@adobe/css-tools@^4.3.2": version "4.3.3" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff" integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ== @@ -37,32 +37,32 @@ dependencies: node-fetch "^2.6.1" -"@asyncapi/avro-schema-parser@^3.0.15": - version "3.0.18" - resolved "https://registry.yarnpkg.com/@asyncapi/avro-schema-parser/-/avro-schema-parser-3.0.18.tgz#c755eb81a652dcef6e885b01fad2f22aba06065a" - integrity sha512-2w8cq10apV7Kc+mBPIaIhmErj1oJF8cERpTZKjFti1uoQCXX3oZ9H0wQCajlYNOANuh/a0F4JMEd2R/lM0PFXw== +"@asyncapi/avro-schema-parser@^3.0.7": + version "3.0.12" + resolved "https://registry.yarnpkg.com/@asyncapi/avro-schema-parser/-/avro-schema-parser-3.0.12.tgz#b1d6a37a7803617624d4bdd778b9cc23512dff70" + integrity sha512-rxU4uyxDa6R3IC8XQ39ww+TWCZpjkz0y+M1w6aIl2N2rVCIkvH7KdYkXoqqkByBY8gY/wYbPrQMbFtaG1/2atg== dependencies: - "@asyncapi/parser" "^3.0.10" + "@asyncapi/parser" "^3.0.5" "@types/json-schema" "^7.0.11" avsc "^5.7.6" -"@asyncapi/openapi-schema-parser@^3.0.15": - version "3.0.18" - resolved "https://registry.yarnpkg.com/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-3.0.18.tgz#3c772f388a2a546c111d22455098e0c5258d84e1" - integrity sha512-azKEwm9wel7QS/Fz0y1C9eCUIfUEZE/JrQlGybPmQKHS213RKyo5Rjpzo9nqGc4VT7o1URDIMaYTnYdgfMitew== +"@asyncapi/openapi-schema-parser@^3.0.8": + version "3.0.13" + resolved "https://registry.yarnpkg.com/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-3.0.13.tgz#fbb696dca8c8cbe221bb0adbdac862bc795f8541" + integrity sha512-52bjqgZobs5Ds5n74Mg9IWbMC1PoKXArcZesF1o2HmvUwDLMkyzqyKeQXgChOi1KMV5DnMzhS5WMO84eeW5VdQ== dependencies: - "@asyncapi/parser" "^3.0.10" + "@asyncapi/parser" "^3.0.5" "@openapi-contrib/openapi-schema-to-json-schema" "~3.2.0" ajv "^8.11.0" ajv-errors "^3.0.0" ajv-formats "^2.1.1" -"@asyncapi/parser@^3.0.10", "@asyncapi/parser@^3.0.7": - version "3.0.10" - resolved "https://registry.yarnpkg.com/@asyncapi/parser/-/parser-3.0.10.tgz#cf2dbf1dcac3e76e75b638545fc23aaceae03dfb" - integrity sha512-x9qo7SHGzPWbC1XCRyilcI+Z6UZsWZ9uRl05h9j4G/v+3IjNG3krwngiAbt59nbLlYZD/nBS7Hc03GayoocnQw== +"@asyncapi/parser@^3.0.1", "@asyncapi/parser@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@asyncapi/parser/-/parser-3.0.5.tgz#61bb77780ac00423fea2ba6c07fd3f96ae7e2d5c" + integrity sha512-Kc/hwCyb2/YzcIfQlY9lwjUDV/9cXMjVewQz9WvPVAaFlOr83bdHpccfnl2sQNXDcC+zCcpEDBjs41ATowPE3Q== dependencies: - "@asyncapi/specs" "^6.5.3" + "@asyncapi/specs" "^6.4.0" "@openapi-contrib/openapi-schema-to-json-schema" "~3.2.0" "@stoplight/json" "^3.20.2" "@stoplight/json-ref-readers" "^1.2.2" @@ -82,34 +82,34 @@ jsonpath-plus "^7.2.0" node-fetch "2.6.7" -"@asyncapi/protobuf-schema-parser@^3.2.4": - version "3.2.8" - resolved "https://registry.yarnpkg.com/@asyncapi/protobuf-schema-parser/-/protobuf-schema-parser-3.2.8.tgz#e745820ae929448e70a290b2dc7452b8e45ac856" - integrity sha512-qYKutNQTkMNrf8BB9d6o2JS/4LC+r6Fkugvg46gW66kN9dKMU2nguHWM+MdcG89nk5keM2Olz4IN8AvWqX9iew== +"@asyncapi/protobuf-schema-parser@^3.0.4": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@asyncapi/protobuf-schema-parser/-/protobuf-schema-parser-3.2.2.tgz#d0940783558511f76212c0eceb98f6302e020820" + integrity sha512-32A5da9U+okya8NMfB73TXz+Q5sqzckPpTaCYvVL6aftMHx1KXwqBLvfbXUX25pR+TTfhJq7QAMHdg0MVFLI9Q== dependencies: - "@asyncapi/parser" "^3.0.10" + "@asyncapi/parser" "^3.0.5" "@types/protocol-buffers-schema" "^3.4.1" protobufjs "^7.2.6" -"@asyncapi/react-component@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@asyncapi/react-component/-/react-component-1.3.1.tgz#1d1e812bb25c9a913de1847cf55d5ce4e04fcbfe" - integrity sha512-mcJnPQV2SLmtTfKstXwP7A4zmlVjazty4yttyynSZEW4t2KmEu3qk5h79R7adONLC2fIiFdlCENyJqJXkfp+Cg== +"@asyncapi/react-component@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@asyncapi/react-component/-/react-component-1.2.6.tgz#8c661fe4f6487b6346ef2c2299c45cc72bba8fd0" + integrity sha512-NgB9sVYrob3e2xrxOgrZLctTeQk4cha/VDiF/sC42ehCtWXFACcZwLCLZfkf2gCCwr6SfO5xAxltLq8NMXNTNg== dependencies: - "@asyncapi/avro-schema-parser" "^3.0.15" - "@asyncapi/openapi-schema-parser" "^3.0.15" - "@asyncapi/parser" "^3.0.7" - "@asyncapi/protobuf-schema-parser" "^3.2.4" + "@asyncapi/avro-schema-parser" "^3.0.7" + "@asyncapi/openapi-schema-parser" "^3.0.8" + "@asyncapi/parser" "^3.0.1" + "@asyncapi/protobuf-schema-parser" "^3.0.4" highlight.js "^10.7.2" isomorphic-dompurify "^0.13.0" marked "^4.0.14" openapi-sampler "^1.2.1" use-resize-observer "^8.0.0" -"@asyncapi/specs@^6.5.3": - version "6.5.3" - resolved "https://registry.yarnpkg.com/@asyncapi/specs/-/specs-6.5.3.tgz#2d032e4f8a7e06410d164baac5c6259b4d8db7a5" - integrity sha512-mZROlCOLkZEWy5tN4pPop3JEJflSKmLLMGO1TebF5wjnroqZ3yp/GuGUxVIl3jVNxFk1i5nZ2AtWzAD/HaUj3Q== +"@asyncapi/specs@^6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@asyncapi/specs/-/specs-6.4.0.tgz#6021a472582815c6e51447c46fb1e43cf4d09544" + integrity sha512-hTw0xF09i+eoSGP8LKo6aM+XOkvWsgV7kYpFHXd45VX9RcVZl5cADFIYDnPZkd52WaDJ4S+8Nrwkt/1vDb6SrQ== dependencies: "@types/json-schema" "^7.0.11" @@ -2033,16 +2033,16 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" -"@backstage/app-defaults@^1.4.7", "@backstage/app-defaults@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@backstage/app-defaults/-/app-defaults-1.5.0.tgz#772e34818b2bfe47423d01f4060ceb11035e257c" - integrity sha512-NiFPfs03sfWHYv5gqDFPuDdaht9vbuQ3y2n3EoFxbcxuUjUoFv8VJ9mQXlEv7t0v25p3QnVOxPZEE25xamhdFw== +"@backstage/app-defaults@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@backstage/app-defaults/-/app-defaults-1.4.7.tgz#9ad472a4db7324040cdd42ed3896b7605c6f7ba8" + integrity sha512-C6T0NGT3DN9IGHrnt5Z/YlS0ysPl2YrgSZuZIJDkkW0oJssnCz07aiLEAXddh3SyKC2v+pNY25NbEJlk/eB52A== dependencies: - "@backstage/core-app-api" "^1.12.0" - "@backstage/core-components" "^0.14.0" - "@backstage/core-plugin-api" "^1.9.0" - "@backstage/plugin-permission-react" "^0.4.20" - "@backstage/theme" "^0.5.1" + "@backstage/core-app-api" "^1.11.3" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/plugin-permission-react" "^0.4.19" + "@backstage/theme" "^0.5.0" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" @@ -2082,45 +2082,6 @@ winston "^3.2.1" winston-transport "^4.5.0" -"@backstage/backend-app-api@^0.6.0": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@backstage/backend-app-api/-/backend-app-api-0.6.1.tgz#ae113abe4d70f5c88515cba866eb1468bc86cecc" - integrity sha512-B34N38JV+fAFXowGGokEvM1sUQ07hrTr1tIk986rXnQPw/MLvvqZEe9lMOgyffzAg9dxssi6wrMKFt0qX/y+3w== - dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/backend-tasks" "^0.5.20" - "@backstage/cli-common" "^0.1.13" - "@backstage/cli-node" "^0.2.4" - "@backstage/config" "^1.2.0" - "@backstage/config-loader" "^1.7.0" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-node" "^0.4.10" - "@backstage/plugin-permission-node" "^0.7.26" - "@backstage/types" "^1.1.1" - "@manypkg/get-packages" "^1.1.3" - "@types/cors" "^2.8.6" - "@types/express" "^4.17.6" - compression "^1.7.4" - cookie "^0.6.0" - cors "^2.8.5" - express "^4.17.1" - express-promise-router "^4.1.0" - fs-extra "^11.2.0" - helmet "^6.0.0" - jose "^5.0.0" - lodash "^4.17.21" - logform "^2.3.2" - minimatch "^9.0.0" - minimist "^1.2.5" - morgan "^1.10.0" - node-forge "^1.3.1" - path-to-regexp "^6.2.1" - selfsigned "^2.0.0" - stoppable "^1.1.0" - winston "^3.2.1" - winston-transport "^4.5.0" - "@backstage/backend-app-api@^0.6.2": version "0.6.2" resolved "https://registry.yarnpkg.com/@backstage/backend-app-api/-/backend-app-api-0.6.2.tgz#69259601d6b0bd909486f48c093db2b3ece0ae07" @@ -2161,9 +2122,9 @@ winston-transport "^4.5.0" "@backstage/backend-common@^0.20.1": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.20.2.tgz#0ce5b7bfcb91918008c4ec6bb6aede72c4474e20" - integrity sha512-hQazpWVhjcOIic1bDMVKZ2pQn9Th4gKmI+1Q5aT2cls7dnXNF7Mwb3bRgnVQk+18bEn6sxHOUyCAFd8KzYTtLg== + version "0.20.1" + resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.20.1.tgz#e9b8bc7d7251ea57b2db52d7c6619dd74caa959f" + integrity sha512-VI3b2Bio+ne/IgVhKh6wP+ogqBVV+vo8ck/n0RHwtukpRc0Gx92M+LPfqf4UxlV7fvY2tYSFXtXLXupeW8aWfQ== dependencies: "@aws-sdk/abort-controller" "^3.347.0" "@aws-sdk/client-s3" "^3.350.0" @@ -2221,7 +2182,7 @@ yauzl "^2.10.0" yn "^4.0.0" -"@backstage/backend-common@^0.21.2", "@backstage/backend-common@^0.21.3", "@backstage/backend-common@^0.21.4", "@backstage/backend-common@^0.21.5", "@backstage/backend-common@^0.21.6": +"@backstage/backend-common@^0.21.2", "@backstage/backend-common@^0.21.6": version "0.21.6" resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.21.6.tgz#e33c744d130839c2e4c596ffb881995dd0fdc489" integrity sha512-JRLBBz3S9h7yCqOs06/rV4qR/lwOmHvIVRP5fEqhhHXQA8jw9kqetIo7SxVDIQwopCjFTLUydpXtPpDcFYdLOA== @@ -2305,7 +2266,7 @@ openapi-merge "^1.3.2" openapi3-ts "^3.1.2" -"@backstage/backend-plugin-api@^0.6.12", "@backstage/backend-plugin-api@^0.6.13", "@backstage/backend-plugin-api@^0.6.14", "@backstage/backend-plugin-api@^0.6.15", "@backstage/backend-plugin-api@^0.6.16", "@backstage/backend-plugin-api@^0.6.9": +"@backstage/backend-plugin-api@^0.6.12", "@backstage/backend-plugin-api@^0.6.16", "@backstage/backend-plugin-api@^0.6.9": version "0.6.16" resolved "https://registry.yarnpkg.com/@backstage/backend-plugin-api/-/backend-plugin-api-0.6.16.tgz#4597ead4bea7aa70bca1633d9f5fefedb818832a" integrity sha512-CUYH9MOkOKtFByA33aw5IeeaCswd9W6EfWFTrGWDbJ1LCA5L0pyUaIySp/q9ziwxkRkBMDU3nGlnz7sjN7L4sg== @@ -2319,7 +2280,7 @@ express "^4.17.1" knex "^3.0.0" -"@backstage/backend-tasks@^0.5.14", "@backstage/backend-tasks@^0.5.17", "@backstage/backend-tasks@^0.5.20", "@backstage/backend-tasks@^0.5.21": +"@backstage/backend-tasks@^0.5.14", "@backstage/backend-tasks@^0.5.17", "@backstage/backend-tasks@^0.5.21": version "0.5.21" resolved "https://registry.yarnpkg.com/@backstage/backend-tasks/-/backend-tasks-0.5.21.tgz#5f8c76f903bfd782f7c9099a19b8ca34f540f8ca" integrity sha512-zZt5GVE9dRwgjSWdssUZHx+jTKXXLqUZgFB1RKeCIzJFjIny+b9NfvGng9z0l9iYY1d7Iablvuz3DtMQCE/yAA== @@ -2338,31 +2299,7 @@ winston "^3.2.1" zod "^3.22.4" -"@backstage/backend-test-utils@^0.3.4": - version "0.3.4" - resolved "https://registry.yarnpkg.com/@backstage/backend-test-utils/-/backend-test-utils-0.3.4.tgz#872229716f29fc9a8dda26a229c3cb5844016e95" - integrity sha512-0x6jxsDcIjTWkPwyRe/BEyHmn0cDxFIQF0AE5w4C7o102fh2QTvwVv6EV4IgHcaMnuXPOK8OyLV4hza/Jqzisg== - dependencies: - "@backstage/backend-app-api" "^0.6.0" - "@backstage/backend-common" "^0.21.4" - "@backstage/backend-plugin-api" "^0.6.14" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-node" "^0.4.9" - "@backstage/types" "^1.1.1" - better-sqlite3 "^9.0.0" - cookie "^0.6.0" - express "^4.17.1" - fs-extra "^11.0.0" - knex "^3.0.0" - msw "^1.0.0" - mysql2 "^3.0.0" - pg "^8.11.3" - testcontainers "^10.0.0" - textextensions "^5.16.0" - uuid "^9.0.0" - -"@backstage/catalog-client@^1.5.2", "@backstage/catalog-client@^1.6.0", "@backstage/catalog-client@^1.6.2", "@backstage/catalog-client@^1.6.3": +"@backstage/catalog-client@^1.5.2", "@backstage/catalog-client@^1.6.0", "@backstage/catalog-client@^1.6.3": version "1.6.3" resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.6.3.tgz#ee09bfc685b7721b4bced2d32b53733c4c16ce48" integrity sha512-yCgc/vi1eVnQ8cFw4+sVuRCWN69aR2LjAqaq+o4Bcq297mAC88qQOp2CdwQvFVoEGhgdfsZ/4SiGjFj+51tYrA== @@ -2387,35 +2324,7 @@ resolved "https://registry.yarnpkg.com/@backstage/cli-common/-/cli-common-0.1.13.tgz#cbeda6a359ca4437fc782f0ac51bb957e8d49e73" integrity sha512-UMgNAIJSeEPSMkzxiWCP8aFR8APsG21XczDnzwHdL/41F7g2C+KA6UeQc/3tzbe8XQo+PxbNLpReZeKSSnSPSQ== -"@backstage/cli-node@^0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@backstage/cli-node/-/cli-node-0.2.2.tgz#f7a6062da90a20ce9d1af161ed841fbeb96337b8" - integrity sha512-YsEeT3sAF2sxNXv7IyI/d73TEZnivSBpyiJ4STnVpFi00woN440NeRWZfqaabS1XiuGbQibxJT3xTxORw1tMFA== - dependencies: - "@backstage/cli-common" "^0.1.13" - "@backstage/errors" "^1.2.3" - "@backstage/types" "^1.1.1" - "@manypkg/get-packages" "^1.1.3" - "@yarnpkg/parsers" "^3.0.0-rc.4" - fs-extra "10.1.0" - semver "^7.5.3" - zod "^3.22.4" - -"@backstage/cli-node@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@backstage/cli-node/-/cli-node-0.2.3.tgz#76d31a0ccd44326d110fb3a38c0db507b79e3ddf" - integrity sha512-gSsRds/xm9nh6jV/XoOipOA8rFwlMPOAoy3vkUyB5+Z5bfEM56NSccYjPdPMt52R9zZhVWhnsMNBHVoaqr+zeg== - dependencies: - "@backstage/cli-common" "^0.1.13" - "@backstage/errors" "^1.2.3" - "@backstage/types" "^1.1.1" - "@manypkg/get-packages" "^1.1.3" - "@yarnpkg/parsers" "^3.0.0-rc.4" - fs-extra "^11.2.0" - semver "^7.5.3" - zod "^3.22.4" - -"@backstage/cli-node@^0.2.4": +"@backstage/cli-node@^0.2.2", "@backstage/cli-node@^0.2.3", "@backstage/cli-node@^0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@backstage/cli-node/-/cli-node-0.2.4.tgz#8706a113427c8bf4a135095624da69ab2fc7ef79" integrity sha512-fCsWB5XOwD4ogp5tI14tydEPcvL3HPoXjYaUiNPf1owomzjIwbLpJnMXBp2SNDemLH+ZwnyqDj55hN+U36qQnA== @@ -2574,36 +2483,17 @@ "@backstage/errors" "^1.2.4" "@backstage/types" "^1.1.1" -"@backstage/core-app-api@^1.11.3", "@backstage/core-app-api@^1.12.0": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@backstage/core-app-api/-/core-app-api-1.12.0.tgz#9e010a938fbfe31a581581da9e842f0e7c248f6c" - integrity sha512-tR/2OcRM7Wlx2cLD5rfcwWpU65A31fjxLAkATYp8i49IGL8rtpJe4udrmws4uppjj27Qc+1PgRzG4qb0UDvllg== +"@backstage/core-app-api@^1.11.3": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@backstage/core-app-api/-/core-app-api-1.11.3.tgz#49d97c6fd1ed051b4520e8afb1400951d93d587f" + integrity sha512-GVs4M5SarJXXW4MByqRQIHTb6B3RHsNsdoLVmd9BmdKJ9hoPh+UFQTUoXhQyO9sbbGLutiQEV5L6kZAaXkW9Fw== dependencies: "@backstage/config" "^1.1.1" - "@backstage/core-plugin-api" "^1.9.0" - "@backstage/types" "^1.1.1" - "@backstage/version-bridge" "^1.0.7" - "@types/prop-types" "^15.7.3" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" - history "^5.0.0" - i18next "^22.4.15" - lodash "^4.17.21" - prop-types "^15.7.2" - react-use "^17.2.4" - zen-observable "^0.10.0" - zod "^3.22.4" - -"@backstage/core-app-api@^1.12.2": - version "1.12.2" - resolved "https://registry.yarnpkg.com/@backstage/core-app-api/-/core-app-api-1.12.2.tgz#49643914a90be8601c7320ec05db53c6be43e801" - integrity sha512-xGwl24Pq9Fml/lLBce4GBGWB2rhhitpnC7iEUV35TTr2IlrPZOjmWmBN0jBJLIStHLWgEMzMzXOaQPjEIzdeyQ== - dependencies: - "@backstage/config" "^1.2.0" - "@backstage/core-plugin-api" "^1.9.1" + "@backstage/core-plugin-api" "^1.8.2" "@backstage/types" "^1.1.1" "@backstage/version-bridge" "^1.0.7" "@types/prop-types" "^15.7.3" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" history "^5.0.0" i18next "^22.4.15" lodash "^4.17.21" @@ -2623,17 +2513,6 @@ "@backstage/version-bridge" "^1.0.7" "@types/react" "^16.13.1 || ^17.0.0" -"@backstage/core-compat-api@^0.2.0", "@backstage/core-compat-api@^0.2.1", "@backstage/core-compat-api@^0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@backstage/core-compat-api/-/core-compat-api-0.2.2.tgz#e30d863a0edbcc4abc4ea3918b416e432e58e1e0" - integrity sha512-TeRr1Fm/Y62Se2+saQoOI7nwZTCr+Or+yM5M5d0gKdhgz/S2tSvMoaRlJiUQrXdpIDH2BkB9wZPUcy+R+XtS9A== - dependencies: - "@backstage/core-app-api" "^1.12.2" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/frontend-plugin-api" "^0.6.2" - "@backstage/version-bridge" "^1.0.7" - "@types/react" "^16.13.1 || ^17.0.0" - "@backstage/core-components@^0.13.10": version "0.13.10" resolved "https://registry.yarnpkg.com/@backstage/core-components/-/core-components-0.13.10.tgz#f423b56c81cade3df20ec5eccb209f0315320290" @@ -2679,15 +2558,15 @@ zen-observable "^0.10.0" zod "^3.22.4" -"@backstage/core-components@^0.14.0", "@backstage/core-components@^0.14.1", "@backstage/core-components@^0.14.2", "@backstage/core-components@^0.14.3": - version "0.14.3" - resolved "https://registry.yarnpkg.com/@backstage/core-components/-/core-components-0.14.3.tgz#3de6ebe04d31ef6dc53b127b12d76490c7bba749" - integrity sha512-2NmGRkvyxJtzPnosfus1gIhP6mqFi9UxeBbjsdpKQTemnSDs6mt52MuGrCgKPvxLAzoLIQz0R4ontQWV045nIA== +"@backstage/core-components@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@backstage/core-components/-/core-components-0.14.0.tgz#f9208617f569badd4dbf3bf270179e1d6dd41e26" + integrity sha512-uIoQJFOghQX9kNk/RjWKYzqc/euq6p6HLYU01ptrCwY81dIChXUU/XulxuT0l1LQq8oAzQPbg6v9l4nU7EJ1yg== dependencies: - "@backstage/config" "^1.2.0" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/errors" "^1.2.4" - "@backstage/theme" "^0.5.2" + "@backstage/config" "^1.1.1" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/errors" "^1.2.3" + "@backstage/theme" "^0.5.1" "@backstage/version-bridge" "^1.0.7" "@date-io/core" "^1.3.13" "@material-table/core" "^3.1.0" @@ -2712,7 +2591,7 @@ rc-progress "3.5.1" react-helmet "6.1.0" react-hook-form "^7.12.2" - react-idle-timer "5.7.2" + react-idle-timer "5.6.2" react-markdown "^8.0.0" react-sparklines "^1.7.0" react-syntax-highlighter "^15.4.5" @@ -2724,13 +2603,13 @@ zen-observable "^0.10.0" zod "^3.22.4" -"@backstage/core-plugin-api@^1.8.2", "@backstage/core-plugin-api@^1.9.0", "@backstage/core-plugin-api@^1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.9.1.tgz#3ad8b7ee247198bb59fcd3b146092e4f9512a5de" - integrity sha512-hV/U08XkgcEgE8YmwfK/onF2V/BlXaq0GxsalNJ5UarQde1XtRLydCg3NJ6oHTqrmzgcLPBAiOzSs+v5Z/SV5A== +"@backstage/core-plugin-api@^1.8.2", "@backstage/core-plugin-api@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.9.0.tgz#49cda87ab82b968c9c7439da99549a4c34c4f720" + integrity sha512-k+w9TfJCFv/5YyiATuZfnlg/8KkJEL0fo9MHGFcOTOeqX0rcb0eecEWmb2kiA4NfPzLmEeNSSc4Nv8zdRQwCQA== dependencies: - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" "@backstage/types" "^1.1.1" "@backstage/version-bridge" "^1.0.7" "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" @@ -2754,13 +2633,13 @@ "@types/react" "^16.13.1 || ^17.0.0" react-use "^17.2.4" -"@backstage/e2e-test-utils@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@backstage/e2e-test-utils/-/e2e-test-utils-0.1.1.tgz#309164a5006593da711f55f430dc4d11a3036514" - integrity sha512-g5knWOQT/vEv3RwMtN3sCz7m43r+jFoO35y7FBuruhGJfzJY4Q4qK+wSGwJ/hSZ7Uj6WBNRsyw8oauMPKiRA5A== +"@backstage/e2e-test-utils@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@backstage/e2e-test-utils/-/e2e-test-utils-0.1.0.tgz#6be33f581b4493d568e9fd7674a9a5804f354482" + integrity sha512-GCsIexoqPpkawvy7fmuzlscKMyOwLEgFuLBYwULIrQsmUAmk0kVccWZTa4l/isoIvXg7L0TSo9kjR95rDsNGEg== dependencies: "@manypkg/get-packages" "^1.1.3" - fs-extra "^11.0.0" + fs-extra "^10.1.0" "@backstage/errors@^1.2.3", "@backstage/errors@^1.2.4": version "1.2.4" @@ -2793,28 +2672,13 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" -"@backstage/frontend-plugin-api@^0.6.0", "@backstage/frontend-plugin-api@^0.6.1", "@backstage/frontend-plugin-api@^0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@backstage/frontend-plugin-api/-/frontend-plugin-api-0.6.2.tgz#20bd4576edf7a1ca01a08bd0b71742ea31755e48" - integrity sha512-bZUK/y4v6drjkoPdIIuTEVBaIlezmYzju/fdXdNbfZmr6qceWoBS1gwJR+jfWxvtqzgxVvqI03RpyEryR7/+/g== - dependencies: - "@backstage/core-components" "^0.14.2" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/types" "^1.1.1" - "@backstage/version-bridge" "^1.0.7" - "@material-ui/core" "^4.12.4" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" - lodash "^4.17.21" - zod "^3.22.4" - zod-to-json-schema "^3.21.4" - -"@backstage/frontend-plugin-api@^0.6.3": - version "0.6.3" - resolved "https://registry.yarnpkg.com/@backstage/frontend-plugin-api/-/frontend-plugin-api-0.6.3.tgz#12a1909fec657eee8a90534fd0b5e3520d719bdb" - integrity sha512-AxfCcfSRp+mGdss2AEZDg84GTsJhfH1goxBpCrAzAZJa4ymvek0lYiDvTDhVVBHx01wMUoka7OWX2YlBd0MJRg== +"@backstage/frontend-plugin-api@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@backstage/frontend-plugin-api/-/frontend-plugin-api-0.6.0.tgz#849ced607fbf503daed29f4c1ea1e4381e3e0c01" + integrity sha512-09M3ftyZGljxTiCURGSHyPaO/ACBAQEL7iH0Kfq20i3c5ReyUjL/eZ/pgk/MGX7AhPheR98XTeHPD9OACfj+JQ== dependencies: - "@backstage/core-components" "^0.14.3" - "@backstage/core-plugin-api" "^1.9.1" + "@backstage/core-components" "^0.14.0" + "@backstage/core-plugin-api" "^1.9.0" "@backstage/types" "^1.1.1" "@backstage/version-bridge" "^1.0.7" "@material-ui/core" "^4.12.4" @@ -2823,7 +2687,7 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" -"@backstage/integration-aws-node@^0.1.12": +"@backstage/integration-aws-node@^0.1.12", "@backstage/integration-aws-node@^0.1.8": version "0.1.12" resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.12.tgz#d2c5ac7c81cd6c2733dcfd24544ad21931ea815d" integrity sha512-bPOBM1a/v3Oo4svOKjQbjvBmaKDqCGfSLBtH2rrp1dj1Mk8Pr+hmvQYQZBHqfc0gTqddRST3gz6GGL2ZKovWUw== @@ -2836,27 +2700,14 @@ "@backstage/config" "^1.2.0" "@backstage/errors" "^1.2.4" -"@backstage/integration-aws-node@^0.1.8": - version "0.1.11" - resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.11.tgz#2900fe695badf4816c9c59e059c013f74438e7cd" - integrity sha512-489G2JThdXBCEdTC1r0G4LhD8ob81iDdYLxtOIWtXabR5TRM9hIYTohEviiDEpL2GpAVbHtCIpXvvM0TMGHLBg== - dependencies: - "@aws-sdk/client-sts" "^3.350.0" - "@aws-sdk/credential-provider-node" "^3.350.0" - "@aws-sdk/credential-providers" "^3.350.0" - "@aws-sdk/types" "^3.347.0" - "@aws-sdk/util-arn-parser" "^3.310.0" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - -"@backstage/integration-react@^1.1.23", "@backstage/integration-react@^1.1.24", "@backstage/integration-react@^1.1.25": - version "1.1.25" - resolved "https://registry.yarnpkg.com/@backstage/integration-react/-/integration-react-1.1.25.tgz#2849e063799b3c2915809ce9785253aefd4dd471" - integrity sha512-WLpAD66mraSOoT2CBXFjFWxIuYAUz/sVVQUYQbnUKHtTOUjILyBcaDhwVRxYPEFjJH2AgKPwTHzxoNpstH60aw== +"@backstage/integration-react@^1.1.23", "@backstage/integration-react@^1.1.24": + version "1.1.24" + resolved "https://registry.yarnpkg.com/@backstage/integration-react/-/integration-react-1.1.24.tgz#2ae41ca6ad73cf5064bbe988229f0c942ba39198" + integrity sha512-C7aIYFCU14drZx9k0knDZeY4uq4oN5gbI4OVYJtQFVdZlgWwUuycxtw8ar9XAEzIl+UgPcpIpIWsbvOLBb8Qaw== dependencies: - "@backstage/config" "^1.2.0" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/integration" "^1.9.1" + "@backstage/config" "^1.1.1" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/integration" "^1.9.0" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@types/react" "^16.13.1 || ^17.0.0" @@ -2876,27 +2727,25 @@ lodash "^4.17.21" luxon "^3.0.0" -"@backstage/plugin-api-docs@^0.11.2": - version "0.11.2" - resolved "https://registry.yarnpkg.com/@backstage/plugin-api-docs/-/plugin-api-docs-0.11.2.tgz#e8394eac8abdf793afad6cc11934308b6f7c08e4" - integrity sha512-13VbPpbOvwet6FzuruEBCarkamuDfg1lJQgmU7Y2wMXjSzdVRuucEHvbMdKgonUKXbcIZ6l5MJ72SZcu0Yxjuw== +"@backstage/plugin-api-docs@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@backstage/plugin-api-docs/-/plugin-api-docs-0.10.3.tgz#e2dedad4d8630a1bf8297521d2d3b0bf872718e5" + integrity sha512-uCpS70w2yw6P4hHpMaJfXZc30zjQBOo08zN+NAS7KOHjJzMF2Bq90/5IQVBi9Ri/bhjw/vO1Z7T9fzCbDZM/yA== dependencies: - "@asyncapi/react-component" "1.3.1" - "@backstage/catalog-model" "^1.4.5" - "@backstage/core-compat-api" "^0.2.2" - "@backstage/core-components" "^0.14.2" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/frontend-plugin-api" "^0.6.2" - "@backstage/plugin-catalog" "^1.18.1" - "@backstage/plugin-catalog-common" "^1.0.22" - "@backstage/plugin-catalog-react" "^1.11.1" - "@backstage/plugin-permission-react" "^0.4.21" + "@asyncapi/react-component" "1.2.6" + "@backstage/catalog-model" "^1.4.3" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/plugin-catalog" "^1.16.1" + "@backstage/plugin-catalog-common" "^1.0.20" + "@backstage/plugin-catalog-react" "^1.9.3" + "@backstage/plugin-permission-react" "^0.4.19" "@graphiql/react" "^0.20.0" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.61" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" - graphiql "3.1.1" + "@types/react" "^16.13.1 || ^17.0.0" + graphiql "3.0.10" graphql "^16.0.0" graphql-config "^5.0.2" graphql-ws "^5.4.1" @@ -2935,157 +2784,111 @@ "@types/express" "^4.17.6" express "^4.17.1" -"@backstage/plugin-auth-backend-module-atlassian-provider@^0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-atlassian-provider/-/plugin-auth-backend-module-atlassian-provider-0.1.7.tgz#35499dc86a6c13865cac672fd232aeefcd1b1d2e" - integrity sha512-AXl/JxO5j5EATcmd/TUhlt6BgRUxmQVZnjVEEQSt18Pq00+zZYUlmCncnj7I1FhaVdBlWoGC53YIBX4m/1wGTw== +"@backstage/plugin-auth-backend-module-atlassian-provider@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-atlassian-provider/-/plugin-auth-backend-module-atlassian-provider-0.1.1.tgz#aa4714308ad59b2aed3dc3635b773fbdfc00d425" + integrity sha512-qH4jzzk62/z5AI/qM4LTRKYZ9223OV3iSFfGmi8qbFtr9MOfhR3/Srio9/nxxGeDlXs4J8hNTo3JnfaQhDkfCg== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/plugin-auth-node" "^0.4.3" express "^4.18.2" passport "^0.7.0" passport-atlassian-oauth2 "^2.1.0" -"@backstage/plugin-auth-backend-module-aws-alb-provider@^0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-aws-alb-provider/-/plugin-auth-backend-module-aws-alb-provider-0.1.7.tgz#9d2faff44b60f0370c7bc0d9ac2ae1f9ae00c12f" - integrity sha512-r8wJiYtph3Wdh/SzscK/Q7wQV5U8IkHfA11AK5SCNK34MIhlq9y4DlFQJx6Kv5pYLpc5Hf/EOkSj030p6GruAg== - dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-backend" "^0.22.2" - "@backstage/plugin-auth-node" "^0.4.10" - jose "^5.0.0" - node-cache "^5.1.2" - node-fetch "^2.6.7" - -"@backstage/plugin-auth-backend-module-gcp-iap-provider@^0.2.10": - version "0.2.10" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-gcp-iap-provider/-/plugin-auth-backend-module-gcp-iap-provider-0.2.10.tgz#1cce03a1a28a8e51e9ade1ccee2a71f0b91d109d" - integrity sha512-ZG9YfBxHKa0OFvZYhHcaVpIX8HWsKeZiiWg+N6EaDHIo2lInd/VgQ2cD/G7jhjwBxDmVUash1n3msiwq80kRLg== +"@backstage/plugin-auth-backend-module-gcp-iap-provider@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-gcp-iap-provider/-/plugin-auth-backend-module-gcp-iap-provider-0.2.3.tgz#71a405cb557f6369ead3592c8919004921f02ecd" + integrity sha512-5e62tOuH4TAk8WvtC51ECu1NRSkdhKHWW/+pSem7ep+wVcq+sbxK9lgCyFOtS7qX4DsUypNUlZfIa0GZMnkBKw== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/errors" "^1.2.3" + "@backstage/plugin-auth-node" "^0.4.3" "@backstage/types" "^1.1.1" - google-auth-library "^9.0.0" + google-auth-library "^8.0.0" -"@backstage/plugin-auth-backend-module-github-provider@^0.1.12": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-github-provider/-/plugin-auth-backend-module-github-provider-0.1.12.tgz#b683fd201e52e971accb2e484cc54fee4eff74b5" - integrity sha512-cckUt7sowB2LQo0tXiZKyDMO7dWJCY433fa2ykTIzWWOBPzcPg7W9RYuNt1vLUzQOK1XBWk26JX2QiebbJepaw== +"@backstage/plugin-auth-backend-module-github-provider@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-github-provider/-/plugin-auth-backend-module-github-provider-0.1.6.tgz#695b7768e521fb32797cbd68e27106cf853939dc" + integrity sha512-0d6z9jxjkPCZpS9xTwvdKmtCGDG0LC7ozh9YqQuTM09wSRC6RLoJ22NhWjJojFsW2e+dZTM/nlkZKEdvtgSQ2A== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/plugin-auth-node" "^0.4.3" passport-github2 "^0.1.12" -"@backstage/plugin-auth-backend-module-gitlab-provider@^0.1.12": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-gitlab-provider/-/plugin-auth-backend-module-gitlab-provider-0.1.12.tgz#368595389ee1af37d10af309fdd934ca2c430ab5" - integrity sha512-dMHcw2Z0FIWydg+AN5s4fLuie41/OkCyEbkjfrZKnCrfhzNT19x9bCXQ4CUaHreHvkF8y06O+vj1Wb/9TmvzXg== +"@backstage/plugin-auth-backend-module-gitlab-provider@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-gitlab-provider/-/plugin-auth-backend-module-gitlab-provider-0.1.6.tgz#e909a15ae06c25d189e0f229ec418b43c6554c49" + integrity sha512-qlC83viI4s/rUESPvtV7Im3Dk/YXb9b7KGgNXShrbJI3ntMFm7PsMe74HhCEv4WFh47Pwt1LUhzSZNJw2FQ7OA== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/plugin-auth-node" "^0.4.3" express "^4.18.2" passport "^0.7.0" passport-gitlab2 "^5.0.0" -"@backstage/plugin-auth-backend-module-google-provider@^0.1.12": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-google-provider/-/plugin-auth-backend-module-google-provider-0.1.12.tgz#ed6b8a763b508e1594931be0352e237c2970d21c" - integrity sha512-G2V6pkmZ19tBYQVTJ5rhKrZRz2E5i5VjSKAY9NkaJeAvZwfPV0EobdNwujE2TqogNGkzWUPRj+lVJqZj6ahn/Q== +"@backstage/plugin-auth-backend-module-google-provider@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-google-provider/-/plugin-auth-backend-module-google-provider-0.1.6.tgz#96ea34a23c7b8e10751e98b2234ca17e9da3e370" + integrity sha512-OkHoVhbTWeLvc/i19W02txqX24PSepan0Mo9mPn/ReSy3ON1NI3um9E4n9dKZ3o3khJKEWOsoX1iET9GpIjqGQ== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" - google-auth-library "^9.0.0" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/plugin-auth-node" "^0.4.3" + google-auth-library "^8.0.0" passport-google-oauth20 "^2.0.0" -"@backstage/plugin-auth-backend-module-microsoft-provider@^0.1.10": - version "0.1.10" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-microsoft-provider/-/plugin-auth-backend-module-microsoft-provider-0.1.10.tgz#0a42fe34215d336e6e25aa7fe3d0894d0adc5b65" - integrity sha512-fktOpFN3n6Q4CABbkuYsulygaXjyMxLnV0zmRDIQGoIhPgvDoCfeSIiHcSMB08WqRQltacEyycI9aBv3daibag== - dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" - express "^4.18.2" - jose "^5.0.0" - lodash "^4.17.21" - node-fetch "^2.6.7" - passport "^0.7.0" - passport-microsoft "^1.0.0" - -"@backstage/plugin-auth-backend-module-oauth2-provider@^0.1.12": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-oauth2-provider/-/plugin-auth-backend-module-oauth2-provider-0.1.12.tgz#92e3b135d63f203c3cef6ab039e904f80b1dab59" - integrity sha512-xtwWWJ1RKyDr6EwlwbLz0uHM2IYp9aOL4uSC42e51fesibmnCt5WHGrglmMtSwMzPpvZB20F1OwVpsJabRAb8w== +"@backstage/plugin-auth-backend-module-oauth2-provider@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-oauth2-provider/-/plugin-auth-backend-module-oauth2-provider-0.1.6.tgz#91556eb0c82a4c74aa64f0ed2fed393c058b2675" + integrity sha512-IWWb46yc9R+t8lQMwamxnaeldW8NEuymrUYWk3alL/hzlhMZN1MW7X1YKfTf2THzf9DuCxfNY0sowhFANBS6ig== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/plugin-auth-node" "^0.4.3" passport "^0.7.0" passport-oauth2 "^1.6.1" -"@backstage/plugin-auth-backend-module-oauth2-proxy-provider@^0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-oauth2-proxy-provider/-/plugin-auth-backend-module-oauth2-proxy-provider-0.1.8.tgz#14dce47a4d3f7fb7f2b7f60dfd7c34d45b5b9fcb" - integrity sha512-YsUtgEvfRIoY5BmH/TP+aRmizTF/GT29E9YX0y9t4THIPbk6wEhPl12JTsee5Dp/6/pXxaR8Mb4BZhxuqiFxSg== - dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-node" "^0.4.10" - jose "^5.0.0" - -"@backstage/plugin-auth-backend-module-oidc-provider@^0.1.6": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-oidc-provider/-/plugin-auth-backend-module-oidc-provider-0.1.6.tgz#6f9a5f8882e51930ffde3b8f2d737bec977ddc77" - integrity sha512-/HtcARAtyT7IoJZt01/iQHmIoThncrnvw9tCyiTRA/OkkLaP4KQG8btTxNUG+zw2xtGRrUQj6GXSHJ4l2wEVtQ== +"@backstage/plugin-auth-backend-module-oauth2-proxy-provider@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-oauth2-proxy-provider/-/plugin-auth-backend-module-oauth2-proxy-provider-0.1.1.tgz#c79458e96d39b6d58243de96f56c23bd47ecb0e7" + integrity sha512-LTl1vy4BD7H5D/npwigU2RVxb/GEi192LUz9C8EZjhN6T+QlrAnwpkiiLkNW3DGTxXxvPzVZC+I/MfP1mUlBTg== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-backend" "^0.22.2" - "@backstage/plugin-auth-node" "^0.4.10" - express "^4.18.2" - openid-client "^5.5.0" - passport "^0.7.0" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/errors" "^1.2.3" + "@backstage/plugin-auth-node" "^0.4.3" + jose "^4.6.0" -"@backstage/plugin-auth-backend-module-okta-provider@^0.0.8": - version "0.0.8" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-okta-provider/-/plugin-auth-backend-module-okta-provider-0.0.8.tgz#ac6de9751151c9a57262160994cf28f4cbb33a31" - integrity sha512-Bbxkemym2z54EOZwSCCsKuB7d3iR3bIU69XcXIKWHNvji7p6M/Ctx8u2AKISbwPVs87wGPxeZQDq3xfNuDYF8Q== +"@backstage/plugin-auth-backend-module-okta-provider@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend-module-okta-provider/-/plugin-auth-backend-module-okta-provider-0.0.2.tgz#5eee2d91ea6bd3b4e572b61847de0c12dfc5c421" + integrity sha512-z8jpXR883unY8wC/WMY2cXg63s08fOON/q51FakV2vpm7SnndAcpVhXr2EqKoWKMbd3RDjjsGsA8GZFesBZlOw== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/plugin-auth-node" "^0.4.10" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/plugin-auth-node" "^0.4.3" "@davidzemon/passport-okta-oauth" "^0.0.5" express "^4.18.2" passport "^0.7.0" -"@backstage/plugin-auth-backend@^0.22.2": - version "0.22.2" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend/-/plugin-auth-backend-0.22.2.tgz#5b60731197d0a11f98ac9d5680ea4998dc9b0d61" - integrity sha512-ygJjHSUDliU8Wx1zncjOvJFRsrdSZ419uR6lqrT1b2tE5FfUI+CNzboZ4f0WVFW6LcpaDrZG33ZaHU840ZK9sg== +"@backstage/plugin-auth-backend@^0.20.3": + version "0.20.3" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-backend/-/plugin-auth-backend-0.20.3.tgz#5e6e4464bb10fb43f8d590925cd788b5bb42e595" + integrity sha512-JCzAh5ZuXxRgQWQ3bZdSds0YgdXxr2S4FtE0kROtSP2eqFXRgBBmP1W4nrnygXcHMtag5wm2rDLx6Sh+kQYLAA== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/catalog-client" "^1.6.2" - "@backstage/catalog-model" "^1.4.5" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-backend-module-atlassian-provider" "^0.1.7" - "@backstage/plugin-auth-backend-module-aws-alb-provider" "^0.1.7" - "@backstage/plugin-auth-backend-module-gcp-iap-provider" "^0.2.10" - "@backstage/plugin-auth-backend-module-github-provider" "^0.1.12" - "@backstage/plugin-auth-backend-module-gitlab-provider" "^0.1.12" - "@backstage/plugin-auth-backend-module-google-provider" "^0.1.12" - "@backstage/plugin-auth-backend-module-microsoft-provider" "^0.1.10" - "@backstage/plugin-auth-backend-module-oauth2-provider" "^0.1.12" - "@backstage/plugin-auth-backend-module-oauth2-proxy-provider" "^0.1.8" - "@backstage/plugin-auth-backend-module-oidc-provider" "^0.1.6" - "@backstage/plugin-auth-backend-module-okta-provider" "^0.0.8" - "@backstage/plugin-auth-node" "^0.4.10" - "@backstage/plugin-catalog-node" "^1.10.0" - "@backstage/types" "^1.1.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/catalog-client" "^1.5.2" + "@backstage/catalog-model" "^1.4.3" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/plugin-auth-backend-module-atlassian-provider" "^0.1.1" + "@backstage/plugin-auth-backend-module-gcp-iap-provider" "^0.2.3" + "@backstage/plugin-auth-backend-module-github-provider" "^0.1.6" + "@backstage/plugin-auth-backend-module-gitlab-provider" "^0.1.6" + "@backstage/plugin-auth-backend-module-google-provider" "^0.1.6" + "@backstage/plugin-auth-backend-module-oauth2-provider" "^0.1.6" + "@backstage/plugin-auth-backend-module-oauth2-proxy-provider" "^0.1.1" + "@backstage/plugin-auth-backend-module-okta-provider" "^0.0.2" + "@backstage/plugin-auth-node" "^0.4.3" + "@backstage/plugin-catalog-node" "^1.6.1" "@google-cloud/firestore" "^7.0.0" - "@node-saml/passport-saml" "^4.0.4" "@types/express" "^4.17.6" "@types/passport" "^1.0.3" compression "^1.7.4" @@ -3095,13 +2898,14 @@ express "^4.17.1" express-promise-router "^4.1.0" express-session "^1.17.1" - fs-extra "^11.2.0" - google-auth-library "^9.0.0" - jose "^5.0.0" + fs-extra "10.1.0" + google-auth-library "^8.0.0" + jose "^4.6.0" + jwt-decode "^3.1.0" knex "^3.0.0" lodash "^4.17.21" luxon "^3.0.0" - minimatch "^9.0.0" + minimatch "^5.0.0" morgan "^1.10.0" node-cache "^5.1.2" node-fetch "^2.6.7" @@ -3110,45 +2914,24 @@ passport-auth0 "^1.4.3" passport-bitbucket-oauth2 "^0.1.2" passport-github2 "^0.1.12" + passport-gitlab2 "^5.0.0" passport-google-oauth20 "^2.0.0" passport-microsoft "^1.0.0" passport-oauth2 "^1.6.1" passport-onelogin-oauth "^0.0.1" - uuid "^9.0.0" + passport-saml "^3.1.2" + uuid "^8.0.0" winston "^3.2.1" yn "^4.0.0" -"@backstage/plugin-auth-node@^0.4.10", "@backstage/plugin-auth-node@^0.4.3", "@backstage/plugin-auth-node@^0.4.7", "@backstage/plugin-auth-node@^0.4.9": - version "0.4.10" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-node/-/plugin-auth-node-0.4.10.tgz#8874c0d37bda644408be1e442e1dcc1bdddd4617" - integrity sha512-C1xg3EIMFFDap8g88NTMW7iZ9PpEl1kCwWmNrOizLqfq7n3m1GBNbWTN1cI6Zsvn9UYsLBBZXPo8dD4XLWYW+A== +"@backstage/plugin-auth-node@^0.4.11", "@backstage/plugin-auth-node@^0.4.3", "@backstage/plugin-auth-node@^0.4.7": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-node/-/plugin-auth-node-0.4.11.tgz#81747130b8d88a8526136a78d5dcad0629497d1d" + integrity sha512-85FmLUUChHu+t4HZrejKuOTZtR4nQnslJ1nx1quypT+7oRGO5/Xla6vTqM1EQw79umxA/sy5lcrbOWQHODFNpg== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/catalog-client" "^1.6.2" - "@backstage/catalog-model" "^1.4.5" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/types" "^1.1.1" - "@types/express" "*" - "@types/passport" "^1.0.3" - express "^4.17.1" - jose "^5.0.0" - lodash "^4.17.21" - node-fetch "^2.6.7" - passport "^0.7.0" - winston "^3.2.1" - zod "^3.22.4" - zod-to-json-schema "^3.21.4" - -"@backstage/plugin-auth-node@^0.4.11": - version "0.4.11" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-node/-/plugin-auth-node-0.4.11.tgz#81747130b8d88a8526136a78d5dcad0629497d1d" - integrity sha512-85FmLUUChHu+t4HZrejKuOTZtR4nQnslJ1nx1quypT+7oRGO5/Xla6vTqM1EQw79umxA/sy5lcrbOWQHODFNpg== - dependencies: - "@backstage/backend-common" "^0.21.6" - "@backstage/backend-plugin-api" "^0.6.16" - "@backstage/catalog-client" "^1.6.3" + "@backstage/backend-common" "^0.21.6" + "@backstage/backend-plugin-api" "^0.6.16" + "@backstage/catalog-client" "^1.6.3" "@backstage/catalog-model" "^1.4.5" "@backstage/config" "^1.2.0" "@backstage/errors" "^1.2.4" @@ -3189,16 +2972,16 @@ uuid "^8.0.0" winston "^3.2.1" -"@backstage/plugin-catalog-backend-module-scaffolder-entity-model@^0.1.13", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model@^0.1.6": - version "0.1.13" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-backend-module-scaffolder-entity-model/-/plugin-catalog-backend-module-scaffolder-entity-model-0.1.13.tgz#3890f7d20ef2030bb7c4e23600e7735129f1a4bb" - integrity sha512-8uK0wU+YNH78IoobkZIVFp9MKRx7A6SwInQWsbilviHEQ57OYUugC6pi+D61Yj2zlLRK/hUX2lqFPZ3C2826eQ== +"@backstage/plugin-catalog-backend-module-scaffolder-entity-model@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-backend-module-scaffolder-entity-model/-/plugin-catalog-backend-module-scaffolder-entity-model-0.1.6.tgz#0ea62233a916b86bf3ae8ba09fd93ff007bac0f0" + integrity sha512-aGawWFUjoJAbITPUdKzF4fzTTJk5hn/bLMF33Ks5iIW1EWokPoVmDR/qErjxKXXChnVTzPwvPdBejV6HPVcs8Q== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/catalog-model" "^1.4.5" - "@backstage/plugin-catalog-common" "^1.0.22" - "@backstage/plugin-catalog-node" "^1.10.0" - "@backstage/plugin-scaffolder-common" "^1.5.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/catalog-model" "^1.4.3" + "@backstage/plugin-catalog-common" "^1.0.20" + "@backstage/plugin-catalog-node" "^1.6.1" + "@backstage/plugin-scaffolder-common" "^1.4.5" "@backstage/plugin-catalog-backend@^1.16.1": version "1.16.2" @@ -3244,14 +3027,14 @@ yn "^4.0.0" zod "^3.22.4" -"@backstage/plugin-catalog-common@^1.0.20", "@backstage/plugin-catalog-common@^1.0.21", "@backstage/plugin-catalog-common@^1.0.22": - version "1.0.22" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-common/-/plugin-catalog-common-1.0.22.tgz#a5ceb222f89f31b0ade96a32ff875b63067755be" - integrity sha512-8bYlGF3yLALLL8LcOey5BpZhCCu9JRq45bwsAx9sEaEQenf09hVPta3hHANl4+YBHWBB7l/OanqMp0+SkKQDLw== +"@backstage/plugin-catalog-common@^1.0.20", "@backstage/plugin-catalog-common@^1.0.21": + version "1.0.21" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-common/-/plugin-catalog-common-1.0.21.tgz#1dba78e151079cab0137158b71427276799d4104" + integrity sha512-7VA76TRzeVkfyefDVR01lAfTQnaHw2ZtlvOjIo+tSlteivZ+wEzJVq9af/ekHYlOGuDsYzDzGgc/P/eRwY67Ag== dependencies: - "@backstage/catalog-model" "^1.4.5" - "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-search-common" "^1.2.11" + "@backstage/catalog-model" "^1.4.4" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-search-common" "^1.2.10" "@backstage/plugin-catalog-graph@^0.3.3": version "0.3.3" @@ -3303,35 +3086,35 @@ react-use "^17.2.4" yaml "^2.0.0" -"@backstage/plugin-catalog-node@^1.10.0", "@backstage/plugin-catalog-node@^1.11.0", "@backstage/plugin-catalog-node@^1.6.1", "@backstage/plugin-catalog-node@^1.7.2": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-node/-/plugin-catalog-node-1.11.0.tgz#02c458f18da1fda9e4863e0ed450a6560c7401ea" - integrity sha512-w2uTngL2fVeXw24Iag24qJhShw8XdS0ijyovuo/xLwjED+xdP+MWXql6mhQnviic29NXnHChGdiVngP3ka8Mlg== +"@backstage/plugin-catalog-node@^1.6.1", "@backstage/plugin-catalog-node@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-node/-/plugin-catalog-node-1.7.2.tgz#50fa76df5b3f3ce9ce845b544a4064c4a2aa0b16" + integrity sha512-SjFKZbPksQMOh731nO9I8iF6p9k0iZZ0KM00UN4q7lCuVQWi+hQumyUw4WjQauUVlnaqBKsFtCha5gDm5I11iQ== dependencies: - "@backstage/backend-plugin-api" "^0.6.16" - "@backstage/catalog-client" "^1.6.3" - "@backstage/catalog-model" "^1.4.5" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-catalog-common" "^1.0.22" - "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-permission-node" "^0.7.27" + "@backstage/backend-plugin-api" "^0.6.12" + "@backstage/catalog-client" "^1.6.0" + "@backstage/catalog-model" "^1.4.4" + "@backstage/errors" "^1.2.3" + "@backstage/plugin-catalog-common" "^1.0.21" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-permission-node" "^0.7.23" "@backstage/types" "^1.1.1" -"@backstage/plugin-catalog-react@^1.10.0", "@backstage/plugin-catalog-react@^1.11.1", "@backstage/plugin-catalog-react@^1.11.2", "@backstage/plugin-catalog-react@^1.9.3": - version "1.11.2" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.11.2.tgz#93191f382e47d3a415419e73b579327c3628148f" - integrity sha512-8n4/UrAhXoQol8rje/vaHAnYQrtlSxF2QVNenxWYLXZRr65wbWj5aa45UddkrcfaBOef7CNEDBoqRH+osHHCZQ== +"@backstage/plugin-catalog-react@^1.10.0", "@backstage/plugin-catalog-react@^1.9.3": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.10.0.tgz#5c0bab60bd2bf854f4778c111e1f06e2db8ae881" + integrity sha512-xeejxqVp20NCtQIlWrOfvI/scWOefu7PsfQ0Eovqn0dULDVKAJTSgULpdm/AwgJ4E3F46voGw4FE/k5Rlf8Glg== dependencies: - "@backstage/catalog-client" "^1.6.3" - "@backstage/catalog-model" "^1.4.5" - "@backstage/core-components" "^0.14.3" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/errors" "^1.2.4" - "@backstage/frontend-plugin-api" "^0.6.3" - "@backstage/integration-react" "^1.1.25" - "@backstage/plugin-catalog-common" "^1.0.22" - "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-permission-react" "^0.4.21" + "@backstage/catalog-client" "^1.6.0" + "@backstage/catalog-model" "^1.4.4" + "@backstage/core-components" "^0.14.0" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/errors" "^1.2.3" + "@backstage/frontend-plugin-api" "^0.6.0" + "@backstage/integration-react" "^1.1.24" + "@backstage/plugin-catalog-common" "^1.0.21" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-permission-react" "^0.4.20" "@backstage/types" "^1.1.1" "@backstage/version-bridge" "^1.0.7" "@material-ui/core" "^4.12.2" @@ -3347,31 +3130,31 @@ yaml "^2.0.0" zen-observable "^0.10.0" -"@backstage/plugin-catalog@^1.16.1", "@backstage/plugin-catalog@^1.18.1": - version "1.18.1" - resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog/-/plugin-catalog-1.18.1.tgz#32c09a2297a19594e0c39db6ad4778ca3f00d924" - integrity sha512-eIP3vpLYjSCWY/eUm6GD9R3r+2U6J/QRtzxJ6f+gLViCoBDSu94+9Tpb0Xf/tmxjjUYkB1mG70Fwx+upvuJTKQ== +"@backstage/plugin-catalog@^1.16.1": + version "1.16.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog/-/plugin-catalog-1.16.1.tgz#be2f7d726a0283739c46a1f28bda9fa1c0fca3ef" + integrity sha512-h0u8O6A+rR4NAvXzCIeMX56etWTbEmBRQx/QHpY7ZfkwxW601OvTKlT0v7t1wOTW9NbXlO4STyoHtrHc5a1geA== dependencies: - "@backstage/catalog-client" "^1.6.2" - "@backstage/catalog-model" "^1.4.5" - "@backstage/core-compat-api" "^0.2.2" - "@backstage/core-components" "^0.14.2" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/errors" "^1.2.4" - "@backstage/frontend-plugin-api" "^0.6.2" - "@backstage/integration-react" "^1.1.25" - "@backstage/plugin-catalog-common" "^1.0.22" - "@backstage/plugin-catalog-react" "^1.11.1" - "@backstage/plugin-permission-react" "^0.4.21" - "@backstage/plugin-scaffolder-common" "^1.5.1" - "@backstage/plugin-search-common" "^1.2.11" - "@backstage/plugin-search-react" "^1.7.8" + "@backstage/catalog-client" "^1.5.2" + "@backstage/catalog-model" "^1.4.3" + "@backstage/core-compat-api" "^0.1.1" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/errors" "^1.2.3" + "@backstage/frontend-plugin-api" "^0.5.0" + "@backstage/integration-react" "^1.1.23" + "@backstage/plugin-catalog-common" "^1.0.20" + "@backstage/plugin-catalog-react" "^1.9.3" + "@backstage/plugin-permission-react" "^0.4.19" + "@backstage/plugin-scaffolder-common" "^1.4.5" + "@backstage/plugin-search-common" "^1.2.10" + "@backstage/plugin-search-react" "^1.7.5" "@backstage/types" "^1.1.1" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.61" "@mui/utils" "^5.14.15" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" dataloader "^2.0.0" expiry-map "^2.0.0" history "^5.0.0" @@ -3387,23 +3170,23 @@ dependencies: "@backstage/backend-plugin-api" "^0.6.9" -"@backstage/plugin-github-actions@^0.6.14": - version "0.6.14" - resolved "https://registry.yarnpkg.com/@backstage/plugin-github-actions/-/plugin-github-actions-0.6.14.tgz#ac917d6bfde4c452b9db2ef2341091aeccc789de" - integrity sha512-PSe2l+K5S/TuVBPg4GMIEjEjFk7kE5TkyqVFweUCtucdErrN3+Wmq+6zo6zXb1vA9hUOFcTueQVYiOXx50mWrA== +"@backstage/plugin-github-actions@^0.6.10": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@backstage/plugin-github-actions/-/plugin-github-actions-0.6.10.tgz#d64e9ba81a2138d36feb5b45209d7701492de364" + integrity sha512-l+OdaQy2m02Or+wTwV7S3i2YTntyJa+ek+8xgQbvpi1eUweRlSM10vMg9o5PtI+CR5njdkPHIMTQyWRP9nFgFw== dependencies: - "@backstage/catalog-model" "^1.4.5" - "@backstage/core-components" "^0.14.3" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/integration" "^1.9.1" - "@backstage/integration-react" "^1.1.25" - "@backstage/plugin-catalog-react" "^1.11.2" + "@backstage/catalog-model" "^1.4.3" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/integration" "^1.8.0" + "@backstage/integration-react" "^1.1.23" + "@backstage/plugin-catalog-react" "^1.9.3" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.61" "@octokit/rest" "^19.0.3" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" - git-url-parse "^14.0.0" + "@types/react" "^16.13.1 || ^17.0.0" + git-url-parse "^13.0.0" luxon "^3.0.0" react-use "^17.2.4" @@ -3439,24 +3222,7 @@ uuid "^9.0.0" zod "^3.22.4" -"@backstage/plugin-permission-node@^0.7.20", "@backstage/plugin-permission-node@^0.7.23", "@backstage/plugin-permission-node@^0.7.24", "@backstage/plugin-permission-node@^0.7.26": - version "0.7.26" - resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-node/-/plugin-permission-node-0.7.26.tgz#9fc242e1fc1c81fb1fd8fe9dd85402dca71fc8fe" - integrity sha512-O35/+BjH+e+XuxSuFvmwX39mnvsvpi0Y/panPqD5wnNkRB0M3D6jIe/NDWayQT0SkKUiglnpZtZX+OFcdWJXRA== - dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-auth-node" "^0.4.10" - "@backstage/plugin-permission-common" "^0.7.13" - "@types/express" "^4.17.6" - express "^4.17.1" - express-promise-router "^4.1.0" - zod "^3.22.4" - zod-to-json-schema "^3.20.4" - -"@backstage/plugin-permission-node@^0.7.27": +"@backstage/plugin-permission-node@^0.7.20", "@backstage/plugin-permission-node@^0.7.23", "@backstage/plugin-permission-node@^0.7.27": version "0.7.27" resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-node/-/plugin-permission-node-0.7.27.tgz#e29f8ec70fdc57af1a813038b8b536d48a621792" integrity sha512-ExNF2NbbVH1BdrtNMlf5DNKjzgsRlABeP4cMHPJeBdSgZs3tYMNkBDRMf5Z/kaHSYRojNFHJWNHyFfKZqRiDxA== @@ -3473,14 +3239,14 @@ zod "^3.22.4" zod-to-json-schema "^3.20.4" -"@backstage/plugin-permission-react@^0.4.19", "@backstage/plugin-permission-react@^0.4.20", "@backstage/plugin-permission-react@^0.4.21": - version "0.4.21" - resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-react/-/plugin-permission-react-0.4.21.tgz#bbdc098fa8eee7d99093b811884528aefc1b1d2c" - integrity sha512-bW5jxhIGbI7Iijt7DK8P8Kh9PhE18v0YdkDIUkX0OkpT8mCIgkP2IP7TuRfTU+HJhOcJp5YDdCbnDP/8uCyD1Q== +"@backstage/plugin-permission-react@^0.4.19", "@backstage/plugin-permission-react@^0.4.20": + version "0.4.20" + resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-react/-/plugin-permission-react-0.4.20.tgz#508bb6bfadaa89a32e891c06bc68b168f10b88bf" + integrity sha512-kP1lmtEtN5XFgPJhnHO5xb++60XyMUmbSjfrT6p+77my3w0qvg8NwGwtg7fingrYJ3pcFGvEgcmL4j7JUfgH7g== dependencies: - "@backstage/config" "^1.2.0" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/plugin-permission-common" "^0.7.13" + "@backstage/config" "^1.1.1" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/plugin-permission-common" "^0.7.12" "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" swr "^2.0.0" @@ -3503,199 +3269,147 @@ yn "^4.0.0" yup "^0.32.9" -"@backstage/plugin-scaffolder-backend-module-azure@^0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-azure/-/plugin-scaffolder-backend-module-azure-0.1.7.tgz#c5702d94efc4ef47db1367105cb6d3dd11960228" - integrity sha512-2CsCj6zLdfTEdB2r5G7Kjf8NzwiLy5Gwau3KDWeVH/5fydMAZW6qI4VevtncJ+xZh1nzwt7AcRQpLxbsgBhz6w== +"@backstage/plugin-scaffolder-backend-module-azure@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-azure/-/plugin-scaffolder-backend-module-azure-0.1.1.tgz#c8ba1b4daae3cf9afe2950ac3cd366aa64c1959f" + integrity sha512-p0lbtVSiG5Z/4Mp9++RVjtJDexcxsZtHt/e89vRLueU9OdqFvacILJeB92WT6C9LR5ddT3+wBNajN2dhCz0yjg== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-scaffolder-node" "^0.2.10" azure-devops-node-api "^12.0.0" yaml "^2.0.0" -"@backstage/plugin-scaffolder-backend-module-bitbucket-cloud@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-bitbucket-cloud/-/plugin-scaffolder-backend-module-bitbucket-cloud-0.1.5.tgz#9aa790d87b5d92fd5ef6181533438c7eb3fcdf57" - integrity sha512-MalvBNeQd4nVOhrxbZGeSgCG2cp2DxrjKxQ93HEAlwep9k3q39nf9VmeDjKG6iyPmNmGBLMPufcSfEVjXY55ww== - dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" - fs-extra "^11.2.0" - node-fetch "^2.6.7" - yaml "^2.0.0" - -"@backstage/plugin-scaffolder-backend-module-bitbucket-server@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-bitbucket-server/-/plugin-scaffolder-backend-module-bitbucket-server-0.1.5.tgz#39483bee6b7f2596e55e923d88c009453a55e5ef" - integrity sha512-Jo8A9PlgGdbD+AcLHRuobftNlHsWE4Lnxo+FpjDNo8jdgceFVszEDSXMnorH7Mf+dRJwzC5f1LsZ6solZvHizw== - dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" - fs-extra "^11.2.0" - node-fetch "^2.6.7" - yaml "^2.0.0" - -"@backstage/plugin-scaffolder-backend-module-bitbucket@^0.2.5": - version "0.2.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-bitbucket/-/plugin-scaffolder-backend-module-bitbucket-0.2.5.tgz#49f7d011931d4b3b1325c494f61586cee63fee08" - integrity sha512-I4p/5jBrU89jMGGF9nhkAOrC/qNhgRQr8CiluvNBvDIfJRWhZwZN4XeHeD4KunAQqI1sF3ke3wggO6/AehKIvA== - dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud" "^0.1.5" - "@backstage/plugin-scaffolder-backend-module-bitbucket-server" "^0.1.5" - "@backstage/plugin-scaffolder-node" "^0.4.1" - fs-extra "^11.2.0" - node-fetch "^2.6.7" - yaml "^2.0.0" - -"@backstage/plugin-scaffolder-backend-module-gerrit@^0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-gerrit/-/plugin-scaffolder-backend-module-gerrit-0.1.7.tgz#41a7c732603fb43c178223485d46844ff940092d" - integrity sha512-KvsZgWrQHAX8Enx4K25qZQqSrNYx++vU4NEIXXow4XPLhGkTiO4L881jDNlZcmeJWW+w8e3jk6xlADLo+lk1RA== +"@backstage/plugin-scaffolder-backend-module-bitbucket@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-bitbucket/-/plugin-scaffolder-backend-module-bitbucket-0.1.1.tgz#5b56f8f3e35c20bd380285f8e4ab6bc94dfa99dc" + integrity sha512-PW0lW/9pltYiRQhlqgxXXVdIZYESdjvYLU7uvXfdnaZUIOboCnAvbIdGFdkJbvgPEogUIIq3W+YcGaxgyAi7xQ== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-scaffolder-node" "^0.2.10" node-fetch "^2.6.7" yaml "^2.0.0" -"@backstage/plugin-scaffolder-backend-module-gitea@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-gitea/-/plugin-scaffolder-backend-module-gitea-0.1.5.tgz#98b158fca634eaf054cb62e06d1d99d29964d9b7" - integrity sha512-3w1/1bRyr7BSywFwmalsvHS3hSJH8bDn5hVOl62Sr6Mbd4LJGyxuwobsxUzppAXKdYM4htpdlp2nphL67Lqg0Q== +"@backstage/plugin-scaffolder-backend-module-gerrit@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-gerrit/-/plugin-scaffolder-backend-module-gerrit-0.1.1.tgz#a52161f1b8e1980ef485f835ae1875ff35438a83" + integrity sha512-5ShekdtgmDDDOLnzJraEGZmYBWvGGV5cCwhPCnZWsgXU4I6PUiY37XbaAv4t6kN4YnzhFELTeDHqDSlk6RRXKQ== dependencies: - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-scaffolder-node" "^0.2.10" node-fetch "^2.6.7" yaml "^2.0.0" -"@backstage/plugin-scaffolder-backend-module-github@^0.2.5": - version "0.2.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-github/-/plugin-scaffolder-backend-module-github-0.2.5.tgz#746ceaa9b8968c938e78fad1317da2da3079dda3" - integrity sha512-XS714G8H2wRfoBLLDlwLrprowa+9djuce4IyTZW/QIqymn1+FNu5nm7fJ9pIatZsed7ZTNBxHr5aWdW9gAbefQ== +"@backstage/plugin-scaffolder-backend-module-github@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-github/-/plugin-scaffolder-backend-module-github-0.1.1.tgz#e9fc23411c10021ef1fe330cc7a1fae06f9a2ef4" + integrity sha512-Phrjuce8GI+vzhWxjrXJEAbRhFkHCvIJK7mtUSnl77D1sbL2MReysI6o7qW13C3S+rrL916rBHWIHJXw5F7XLg== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-scaffolder-node" "^0.2.10" "@octokit/webhooks" "^10.0.0" libsodium-wrappers "^0.7.11" octokit "^3.0.0" - octokit-plugin-create-pull-request "^5.0.0" + octokit-plugin-create-pull-request "^3.10.0" winston "^3.2.1" yaml "^2.0.0" -"@backstage/plugin-scaffolder-backend-module-gitlab@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-gitlab/-/plugin-scaffolder-backend-module-gitlab-0.3.1.tgz#675675d23b9795e581fc17a18ad77ec3f3829b24" - integrity sha512-k9Ik+xDHZG/4RZ91ul+vivp205Jp+86hjR/gaJ/OGul3SHyLDPpU7yp2nwJDw25smkB5QKlgFL1+kz0tmY2+lw== +"@backstage/plugin-scaffolder-backend-module-gitlab@^0.2.12": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend-module-gitlab/-/plugin-scaffolder-backend-module-gitlab-0.2.12.tgz#10cf7f69be0379c1e221fc82a177816969f01190" + integrity sha512-20VW3fAk4xkyE4bRwRz9rd5S2p9W3Tgmu9ITX7RB1qmEUTqWfAVIRQdkt18gN459srxpypwm7Kh97/I7o7X8KQ== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-scaffolder-node" "^0.2.10" "@gitbeaker/core" "^35.8.0" "@gitbeaker/node" "^35.8.0" "@gitbeaker/rest" "^39.25.0" - luxon "^3.0.0" yaml "^2.0.0" zod "^3.22.4" -"@backstage/plugin-scaffolder-backend@^1.22.2": - version "1.22.2" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend/-/plugin-scaffolder-backend-1.22.2.tgz#097b5e063de2935bde1db792d791a02a9279b674" - integrity sha512-wrmx14lmCb6egVWKUNZo3dGH/DSTwUa7K18QIyQfd4kn2C9Xw1rL4WnWVQ59Hs0FD9qoerq77gREojuCgLQDpQ== +"@backstage/plugin-scaffolder-backend@^1.20.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-backend/-/plugin-scaffolder-backend-1.20.0.tgz#016d41d2dce8d128d632e5c81f3e31195ac9abfe" + integrity sha512-LUhMgH58VNsUa/yBX0GOwwAkb9q0O6CA6p9PzCH5SeOnkJ6v6dMhkgzWgrfjsy0yktmSRb1d0PNdQFdGMzvszA== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/backend-tasks" "^0.5.20" - "@backstage/catalog-client" "^1.6.2" - "@backstage/catalog-model" "^1.4.5" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-auth-node" "^0.4.10" - "@backstage/plugin-catalog-backend-module-scaffolder-entity-model" "^0.1.13" - "@backstage/plugin-catalog-node" "^1.10.0" - "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-permission-node" "^0.7.26" - "@backstage/plugin-scaffolder-backend-module-azure" "^0.1.7" - "@backstage/plugin-scaffolder-backend-module-bitbucket" "^0.2.5" - "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud" "^0.1.5" - "@backstage/plugin-scaffolder-backend-module-bitbucket-server" "^0.1.5" - "@backstage/plugin-scaffolder-backend-module-gerrit" "^0.1.7" - "@backstage/plugin-scaffolder-backend-module-gitea" "^0.1.5" - "@backstage/plugin-scaffolder-backend-module-github" "^0.2.5" - "@backstage/plugin-scaffolder-backend-module-gitlab" "^0.3.1" - "@backstage/plugin-scaffolder-common" "^1.5.1" - "@backstage/plugin-scaffolder-node" "^0.4.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/backend-tasks" "^0.5.14" + "@backstage/catalog-client" "^1.5.2" + "@backstage/catalog-model" "^1.4.3" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-auth-node" "^0.4.3" + "@backstage/plugin-catalog-backend-module-scaffolder-entity-model" "^0.1.6" + "@backstage/plugin-catalog-node" "^1.6.1" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-permission-node" "^0.7.20" + "@backstage/plugin-scaffolder-backend-module-azure" "^0.1.1" + "@backstage/plugin-scaffolder-backend-module-bitbucket" "^0.1.1" + "@backstage/plugin-scaffolder-backend-module-gerrit" "^0.1.1" + "@backstage/plugin-scaffolder-backend-module-github" "^0.1.1" + "@backstage/plugin-scaffolder-backend-module-gitlab" "^0.2.12" + "@backstage/plugin-scaffolder-common" "^1.4.5" + "@backstage/plugin-scaffolder-node" "^0.2.10" "@backstage/types" "^1.1.1" "@types/express" "^4.17.6" "@types/luxon" "^3.0.0" express "^4.17.1" express-promise-router "^4.1.0" - fs-extra "^11.2.0" + fs-extra "10.1.0" globby "^11.0.0" isbinaryfile "^5.0.0" isolated-vm "^4.5.0" jsonschema "^1.2.6" knex "^3.0.0" lodash "^4.17.21" - logform "^2.3.2" luxon "^3.0.0" nunjucks "^3.2.3" p-limit "^3.1.0" p-queue "^6.6.2" - prom-client "^15.0.0" - uuid "^9.0.0" + prom-client "^14.0.1" + uuid "^8.2.0" winston "^3.2.1" yaml "^2.0.0" zen-observable "^0.10.0" zod "^3.22.4" -"@backstage/plugin-scaffolder-common@^1.4.5", "@backstage/plugin-scaffolder-common@^1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-common/-/plugin-scaffolder-common-1.5.1.tgz#cd79c2b222ae03a6906f1599d71c1ef385710f57" - integrity sha512-4ULWyWb7U8N4iUP6LR7SleS1G3pmMkeAvZ/u2OFWyWp1kU2Mgx+SfskZDYNgVb8T4viNlU6nKlsYCkcOSrf4Hw== +"@backstage/plugin-scaffolder-common@^1.4.5": + version "1.4.5" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-common/-/plugin-scaffolder-common-1.4.5.tgz#4e578416ff3648d8cfad0acd5eb4ef8f6811d7d6" + integrity sha512-JSOpUpLxwvYpjqTRPjcFLxa7Z1ngAnwV5ijI06ASboB+dai9IPIGATW57CfvF2u5Vn+wxaXQ6Tc8Pr9gwCdp4A== dependencies: - "@backstage/catalog-model" "^1.4.5" - "@backstage/plugin-permission-common" "^0.7.13" + "@backstage/catalog-model" "^1.4.3" + "@backstage/plugin-permission-common" "^0.7.12" "@backstage/types" "^1.1.1" -"@backstage/plugin-scaffolder-node@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-node/-/plugin-scaffolder-node-0.4.1.tgz#718563a5885bb38acafa4a8c8b5cc2b5ecc12550" - integrity sha512-ZfqRK4UyFaLS3hgcq+GYGs+o9CM8KMzYf8HKA6t+Bxq70k6J11nws5mJ9j8uIZqANBNNkukvO3/ZbqpVkJCtqA== +"@backstage/plugin-scaffolder-node@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-node/-/plugin-scaffolder-node-0.2.10.tgz#ba0b97e4e8e86d48b991bc4233d64d3d9f389c5c" + integrity sha512-3/JQL5JKhRchd/N2gTLTSEnHFhkfCcbSUdZoQDSj4bTkTALl7vNeR6XpW4QqOuKhnaDGndrLORtUiNiCugjRCA== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/catalog-model" "^1.4.5" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/plugin-scaffolder-common" "^1.5.1" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/catalog-model" "^1.4.3" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-scaffolder-common" "^1.4.5" "@backstage/types" "^1.1.1" - fs-extra "^11.2.0" + fs-extra "10.1.0" globby "^11.0.0" jsonschema "^1.2.6" p-limit "^3.1.0" @@ -3807,60 +3521,60 @@ "@backstage/plugin-search-backend-node" "^1.2.13" "@backstage/plugin-search-common" "^1.2.10" -"@backstage/plugin-search-backend-module-pg@^0.5.24": - version "0.5.24" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-module-pg/-/plugin-search-backend-module-pg-0.5.24.tgz#1ad5f4be95283a499391b627aa73012631025581" - integrity sha512-q8ex5OCJvOZPzvKgCfwZFPvJeRhx64o0/B21fx9URFYPUfBIntyURpXT7Eql5/l+HKHIUj04vySikwPOpN2orA== +"@backstage/plugin-search-backend-module-pg@^0.5.18": + version "0.5.18" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-module-pg/-/plugin-search-backend-module-pg-0.5.18.tgz#63846cd6cd744f057f96330d8586490a4bd8dd6c" + integrity sha512-jC+uTIKBxltj8huFRz7x+c1xCi/VD4efa+fWOtY7ZfWfDUm3OsOeXAMhwl7mC8gMYMdvF4kEKWLYCUi3r6KGrw== dependencies: - "@backstage/backend-common" "^0.21.5" - "@backstage/backend-plugin-api" "^0.6.15" - "@backstage/config" "^1.2.0" - "@backstage/plugin-search-backend-node" "^1.2.19" - "@backstage/plugin-search-common" "^1.2.11" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/config" "^1.1.1" + "@backstage/plugin-search-backend-node" "^1.2.13" + "@backstage/plugin-search-common" "^1.2.10" knex "^3.0.0" lodash "^4.17.21" - uuid "^9.0.0" + uuid "^8.3.2" winston "^3.2.1" -"@backstage/plugin-search-backend-module-techdocs@^0.1.17", "@backstage/plugin-search-backend-module-techdocs@^0.1.21": - version "0.1.21" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-module-techdocs/-/plugin-search-backend-module-techdocs-0.1.21.tgz#20818095fa418bea3370095804f66459102055fb" - integrity sha512-8L2bUOtt89SmsftxRHpJ6XA0SBdwhUHNSDhUk53YgccWxta8fDg27ZzeROsF/xI6DZTWe0lUODflEPA634tGhw== +"@backstage/plugin-search-backend-module-techdocs@^0.1.13": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-module-techdocs/-/plugin-search-backend-module-techdocs-0.1.13.tgz#b09f0024780adbda7d8d92cb2a4db145317da932" + integrity sha512-af6dmC1MqJoplle1/1oz1lT3zdGz2HS6uxDH3fMtBQsizbUQgkIRo+2cNtS3GI7dlVidQNadm5MZeXhJNJh9SQ== dependencies: - "@backstage/backend-common" "^0.21.6" - "@backstage/backend-plugin-api" "^0.6.16" - "@backstage/backend-tasks" "^0.5.21" - "@backstage/catalog-client" "^1.6.3" - "@backstage/catalog-model" "^1.4.5" - "@backstage/config" "^1.2.0" - "@backstage/plugin-catalog-common" "^1.0.22" - "@backstage/plugin-catalog-node" "^1.11.0" - "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-search-backend-node" "^1.2.20" - "@backstage/plugin-search-common" "^1.2.11" - "@backstage/plugin-techdocs-node" "^1.12.2" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/backend-tasks" "^0.5.14" + "@backstage/catalog-client" "^1.5.2" + "@backstage/catalog-model" "^1.4.3" + "@backstage/config" "^1.1.1" + "@backstage/plugin-catalog-common" "^1.0.20" + "@backstage/plugin-catalog-node" "^1.6.1" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-search-backend-node" "^1.2.13" + "@backstage/plugin-search-common" "^1.2.10" + "@backstage/plugin-techdocs-node" "^1.11.1" lodash "^4.17.21" node-fetch "^2.6.7" p-limit "^3.1.0" winston "^3.2.1" -"@backstage/plugin-search-backend-node@^1.2.13", "@backstage/plugin-search-backend-node@^1.2.19", "@backstage/plugin-search-backend-node@^1.2.20": - version "1.2.20" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-node/-/plugin-search-backend-node-1.2.20.tgz#90c6fb2c11499a13fed11ef4ec766e85efd8aa2d" - integrity sha512-hrLMT+G5q9OS/it++GBvnF5RGy7V8R/3d4sc/cWWvpQsT01CqFxCfz8lIGm9OTi7btmJ1xNiwlefMtTJ/NS2pg== +"@backstage/plugin-search-backend-node@^1.2.13": + version "1.2.13" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-backend-node/-/plugin-search-backend-node-1.2.13.tgz#2b6af884321b2550ed43a0d092feae9facb5f68b" + integrity sha512-Vg/XFIqKN3Q/oCFivGwra8eoq8w+WmC5i+uyEIUJyB3hisDe57zg5m89fKQGuNSZVRYxWMbyqZvqj8izBkqp4g== dependencies: - "@backstage/backend-common" "^0.21.6" - "@backstage/backend-plugin-api" "^0.6.16" - "@backstage/backend-tasks" "^0.5.21" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/plugin-permission-common" "^0.7.13" - "@backstage/plugin-search-common" "^1.2.11" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/backend-tasks" "^0.5.14" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-search-common" "^1.2.10" "@types/lunr" "^2.3.3" lodash "^4.17.21" lunr "^2.3.9" ndjson "^2.0.0" - uuid "^9.0.0" + uuid "^8.3.2" winston "^3.2.1" "@backstage/plugin-search-backend@^1.4.9": @@ -3888,138 +3602,118 @@ yn "^4.0.0" zod "^3.22.4" -"@backstage/plugin-search-common@^1.2.10", "@backstage/plugin-search-common@^1.2.11": - version "1.2.11" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-common/-/plugin-search-common-1.2.11.tgz#5563f9b7b5ff915d1fe0e0e213c9536029dac91c" - integrity sha512-b2gmurxNdgY6LQ4E+BzITVUFF5jCewjlkI4/oppFTsk1IH+VfQyRDoGb8u2wuYKGCwvgVPgP3qUBEo25oGTZfg== +"@backstage/plugin-search-common@^1.2.10": + version "1.2.10" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-common/-/plugin-search-common-1.2.10.tgz#c9a8294e546a124ff1eca50dbb6d04bbb2143e37" + integrity sha512-yn18Beo+oXocDLfbOoCwFd8p8n7veGyDxKN/8rL6EZjMjNL13ZFvUIAOSrhg7wcx/avLKGwpYoFxMde2LxD/cA== dependencies: - "@backstage/plugin-permission-common" "^0.7.13" + "@backstage/plugin-permission-common" "^0.7.12" "@backstage/types" "^1.1.1" -"@backstage/plugin-search-react@^1.7.5", "@backstage/plugin-search-react@^1.7.6": - version "1.7.6" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-react/-/plugin-search-react-1.7.6.tgz#8a9e97b0486acd052dfeb73bf19e6f4598ab0b64" - integrity sha512-3MPZA35KvKminmEbu3seix1OmRrtP+9NSr21Zenu5BpoPDBE2wNW6gmJH0k0OWR/kibtFQed2nctv77heLIy6w== +"@backstage/plugin-search-react@^1.7.5": + version "1.7.5" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search-react/-/plugin-search-react-1.7.5.tgz#2ee2079db75a988965d20a99280f1533351b1007" + integrity sha512-c69LMiMLDxHBd21g+MXjm3vzcAUMyjLxtFtsjfm43eikj0nf0eUxwzVKua/QuHVtjsvMA8pQmHBGCGCJgqWWLw== dependencies: - "@backstage/core-components" "^0.14.0" - "@backstage/core-plugin-api" "^1.9.0" - "@backstage/frontend-plugin-api" "^0.6.0" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/frontend-plugin-api" "^0.5.0" "@backstage/plugin-search-common" "^1.2.10" - "@backstage/theme" "^0.5.1" - "@backstage/types" "^1.1.1" - "@backstage/version-bridge" "^1.0.7" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.61" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" - lodash "^4.17.21" - qs "^6.9.4" - react-use "^17.3.2" - -"@backstage/plugin-search-react@^1.7.8": - version "1.7.8" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search-react/-/plugin-search-react-1.7.8.tgz#23bbe64086ce5915cac7d569a22bfdf3ec9f1e43" - integrity sha512-bZJ4bIRbsA6vn0Jo96q4+m6Yrh2GQAnsgCIXZDASbvx3PYHRuUvasImVQ5LSmyk5/4k/pGoGOU7P5DAeh94cKQ== - dependencies: - "@backstage/core-components" "^0.14.2" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/frontend-plugin-api" "^0.6.2" - "@backstage/plugin-search-common" "^1.2.11" - "@backstage/theme" "^0.5.2" + "@backstage/theme" "^0.5.0" "@backstage/types" "^1.1.1" "@backstage/version-bridge" "^1.0.7" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.61" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" lodash "^4.17.21" qs "^6.9.4" react-use "^17.3.2" -"@backstage/plugin-search@^1.4.6": - version "1.4.6" - resolved "https://registry.yarnpkg.com/@backstage/plugin-search/-/plugin-search-1.4.6.tgz#34b807b7f5ca0d8e6f91483ca223ea2983c01502" - integrity sha512-QZkJEUc6rlLr6JMSDuI8PAZL7uXs2SwBorH+Yv49//0LUp+ktPd3wIs28aq0I2EB7LoXQNZtTnNZkh7n8JeGZw== +"@backstage/plugin-search@^1.4.5": + version "1.4.5" + resolved "https://registry.yarnpkg.com/@backstage/plugin-search/-/plugin-search-1.4.5.tgz#fde56441e090a675651ba68531348dbca121c356" + integrity sha512-+4VhTMl7zdjdMm/pzhNpeO/d3K+Ner1m5dYoldJs3uMYmh3PEhiwtSbCg4MO3Imb+fkYDknPZlQ7AlqwGbP1Yw== dependencies: - "@backstage/core-compat-api" "^0.2.0" - "@backstage/core-components" "^0.14.0" - "@backstage/core-plugin-api" "^1.9.0" + "@backstage/core-compat-api" "^0.1.1" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" "@backstage/errors" "^1.2.3" - "@backstage/frontend-plugin-api" "^0.6.0" - "@backstage/plugin-catalog-react" "^1.10.0" + "@backstage/frontend-plugin-api" "^0.5.0" + "@backstage/plugin-catalog-react" "^1.9.3" "@backstage/plugin-search-common" "^1.2.10" - "@backstage/plugin-search-react" "^1.7.6" + "@backstage/plugin-search-react" "^1.7.5" "@backstage/types" "^1.1.1" "@backstage/version-bridge" "^1.0.7" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" qs "^6.9.4" react-use "^17.2.4" -"@backstage/plugin-tech-radar@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@backstage/plugin-tech-radar/-/plugin-tech-radar-0.7.0.tgz#3711d5e474d7453e44dad8ae0755375e6d5e2340" - integrity sha512-vRIhBRdyw140eM2xLsmy3b82BTfgEwwBei8VfheHKeS8AcRzkX+6m5n7M9+TkOVMdukss8PMhDZ3iXn88zYWLA== +"@backstage/plugin-tech-radar@^0.6.12": + version "0.6.12" + resolved "https://registry.yarnpkg.com/@backstage/plugin-tech-radar/-/plugin-tech-radar-0.6.12.tgz#b7c304d8ae2a2ef1d5add841ad28336871a1022a" + integrity sha512-wY9dUvkweo/9yW0Ii7esynvuOd/dGz/spU3E6szkEFXodluBOD5U1D91eURvBAghqWv8Ms6GOHzLhj5bfwxCYA== dependencies: - "@backstage/core-compat-api" "^0.2.1" - "@backstage/core-components" "^0.14.1" - "@backstage/core-plugin-api" "^1.9.1" - "@backstage/frontend-plugin-api" "^0.6.1" + "@backstage/core-compat-api" "^0.1.1" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/frontend-plugin-api" "^0.5.0" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" color "^4.0.1" d3-force "^3.0.0" react-use "^17.2.4" -"@backstage/plugin-techdocs-backend@^1.9.6": - version "1.9.6" - resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-backend/-/plugin-techdocs-backend-1.9.6.tgz#6f45f906db0414ffa56fb3067db4630e752594ab" - integrity sha512-YCBuSxn1B8jDHGltxudStRsAunkZoJZX4/xrWsoV68015X/WLS2YB7ensA8nFzcUEPRYbtq0XcKPBj7g5uWqVQ== +"@backstage/plugin-techdocs-backend@^1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-backend/-/plugin-techdocs-backend-1.9.2.tgz#06911a35cd21ba353a2f7a8e46c1afa541833048" + integrity sha512-FlYa7BER9SnT/pgrBhMckqmSsnOww+qNSEefc8hnAwp2LutUMlkhWiBWQ3X9kZ7BieRZq6DbwY/T48PuVz7Tuw== dependencies: - "@backstage/backend-common" "^0.21.3" - "@backstage/backend-plugin-api" "^0.6.13" - "@backstage/catalog-client" "^1.6.0" - "@backstage/catalog-model" "^1.4.4" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/catalog-client" "^1.5.2" + "@backstage/catalog-model" "^1.4.3" "@backstage/config" "^1.1.1" "@backstage/errors" "^1.2.3" - "@backstage/integration" "^1.9.0" - "@backstage/plugin-catalog-common" "^1.0.21" + "@backstage/integration" "^1.8.0" + "@backstage/plugin-catalog-common" "^1.0.20" "@backstage/plugin-permission-common" "^0.7.12" - "@backstage/plugin-search-backend-module-techdocs" "^0.1.17" - "@backstage/plugin-techdocs-node" "^1.11.5" + "@backstage/plugin-search-backend-module-techdocs" "^0.1.13" + "@backstage/plugin-techdocs-node" "^1.11.1" "@types/express" "^4.17.6" - dockerode "^4.0.0" + dockerode "^3.3.1" express "^4.17.1" express-promise-router "^4.1.0" - fs-extra "^11.2.0" + fs-extra "10.1.0" knex "^3.0.0" lodash "^4.17.21" node-fetch "^2.6.7" p-limit "^3.1.0" winston "^3.2.1" -"@backstage/plugin-techdocs-module-addons-contrib@^1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-module-addons-contrib/-/plugin-techdocs-module-addons-contrib-1.1.5.tgz#a590fa2d0db5026300d44103e5499d2e2d987378" - integrity sha512-AP6wnawcTD3ElV9WddxuaXx3m8cEupWTsIaIHYJ27fN1XFqcsFdQ1g9yMxv3v58ie+em+LvvqvdEAdI4co6HnA== +"@backstage/plugin-techdocs-module-addons-contrib@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-module-addons-contrib/-/plugin-techdocs-module-addons-contrib-1.1.4.tgz#91338470076ae0a15b05c27a9bd8d43b2c0d6d5b" + integrity sha512-Eht9iQ2MdQioRRzFE4xPmFH726sIbr1mh75jw1uefiI05z3ZyNR3LslecDN2KCXsTQUt5zgN4aQwsacjifdhSQ== dependencies: - "@backstage/core-components" "^0.14.0" - "@backstage/core-plugin-api" "^1.9.0" - "@backstage/integration" "^1.9.0" - "@backstage/integration-react" "^1.1.24" - "@backstage/plugin-techdocs-react" "^1.1.16" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" + "@backstage/integration" "^1.8.0" + "@backstage/integration-react" "^1.1.23" + "@backstage/plugin-techdocs-react" "^1.1.15" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" - "@react-hookz/web" "^24.0.0" - git-url-parse "^14.0.0" + "@react-hookz/web" "^23.0.0" + git-url-parse "^13.0.0" photoswipe "^5.3.7" -"@backstage/plugin-techdocs-node@^1.11.5", "@backstage/plugin-techdocs-node@^1.12.2": - version "1.12.2" - resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-node/-/plugin-techdocs-node-1.12.2.tgz#c29b3662d07a2502c2d0b97db9180de6cf0edfc6" - integrity sha512-Q5MyD40K8N7uRxmZdjsXzremMtY+KeR+Xh+u0ZPeq3r01LUG4d70S/kSnIYnuuXHtpvhpJ2ZO1OAQSgyREV9VQ== +"@backstage/plugin-techdocs-node@^1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-node/-/plugin-techdocs-node-1.11.1.tgz#2f151ef6a62d3b932b2c2f5849a5c8e43f3d8339" + integrity sha512-S8BAEY4qOR20X/5XauZpmQH3wBDJt2oXnCVW3i+hrC4OtObGokhF5vGgU3VlJPCnlQXlVGmfQkxSSneIqT5xHQ== dependencies: "@aws-sdk/client-s3" "^3.350.0" "@aws-sdk/credential-providers" "^3.350.0" @@ -4027,21 +3721,21 @@ "@aws-sdk/types" "^3.347.0" "@azure/identity" "^4.0.0" "@azure/storage-blob" "^12.5.0" - "@backstage/backend-common" "^0.21.6" - "@backstage/backend-plugin-api" "^0.6.16" - "@backstage/catalog-model" "^1.4.5" - "@backstage/config" "^1.2.0" - "@backstage/errors" "^1.2.4" - "@backstage/integration" "^1.9.1" - "@backstage/integration-aws-node" "^0.1.12" - "@backstage/plugin-search-common" "^1.2.11" + "@backstage/backend-common" "^0.20.1" + "@backstage/backend-plugin-api" "^0.6.9" + "@backstage/catalog-model" "^1.4.3" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/integration" "^1.8.0" + "@backstage/integration-aws-node" "^0.1.8" + "@backstage/plugin-search-common" "^1.2.10" "@google-cloud/storage" "^7.0.0" "@smithy/node-http-handler" "^2.1.7" - "@trendyol-js/openstack-swift-sdk" "^0.0.7" + "@trendyol-js/openstack-swift-sdk" "^0.0.6" "@types/express" "^4.17.6" express "^4.17.1" - fs-extra "^11.2.0" - git-url-parse "^14.0.0" + fs-extra "10.1.0" + git-url-parse "^13.0.0" hpagent "^1.2.0" js-yaml "^4.0.0" json5 "^2.1.3" @@ -4050,19 +3744,19 @@ recursive-readdir "^2.2.2" winston "^3.2.1" -"@backstage/plugin-techdocs-react@^1.1.15", "@backstage/plugin-techdocs-react@^1.1.16": - version "1.1.16" - resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-react/-/plugin-techdocs-react-1.1.16.tgz#e71757282b450708184058f2e3c7124c3909a454" - integrity sha512-AQd373U13riDaWbPg+EiyIqNh4T00tM2OviDPmY0DjV2TCNOGczH/WiNt3y/Q56HYBvN02vbmGt1RnjBlvkQKw== +"@backstage/plugin-techdocs-react@^1.1.15": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@backstage/plugin-techdocs-react/-/plugin-techdocs-react-1.1.15.tgz#6902aa7e95c652995c92f3ecd712de998b1d5c70" + integrity sha512-Kc1nhVN//Dt+7+EvPFNu0RtRfT+F7TApZZ9TQP3zabLJTGDms3aKWZJJhNHCE3e5QlXX4uuUMojQaHGEjjbF3w== dependencies: - "@backstage/catalog-model" "^1.4.4" + "@backstage/catalog-model" "^1.4.3" "@backstage/config" "^1.1.1" - "@backstage/core-components" "^0.14.0" - "@backstage/core-plugin-api" "^1.9.0" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" "@backstage/version-bridge" "^1.0.7" "@material-ui/core" "^4.12.2" "@material-ui/styles" "^4.11.0" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" jss "~10.10.0" lodash "^4.17.21" react-helmet "6.1.0" @@ -4100,24 +3794,24 @@ react-helmet "6.1.0" react-use "^17.2.4" -"@backstage/plugin-user-settings@^0.8.1": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@backstage/plugin-user-settings/-/plugin-user-settings-0.8.1.tgz#f5a345e1a4f63f67227cf134c8d57732b3d963ab" - integrity sha512-lVJ3Vn3C9TPOvxEgl2Dd9E83rz9tnrCFELzaCBud/tEAO8cXNskD1Xlhf0GgXbOMsQ515udBSLoApiKHaHdU3w== +"@backstage/plugin-user-settings@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@backstage/plugin-user-settings/-/plugin-user-settings-0.8.0.tgz#a82c10651b559b15b2fdd12652388705fdbb9d2c" + integrity sha512-H6tNvjqLWiSM3MS/sBkKdrUGgT7ou7DMPXYWy6dl1bSRMLeYrmLwH7gjbkwhAo6wojjCG6cbcnWOFcYZ9EQHoA== dependencies: - "@backstage/core-app-api" "^1.12.0" - "@backstage/core-compat-api" "^0.2.0" - "@backstage/core-components" "^0.14.0" - "@backstage/core-plugin-api" "^1.9.0" + "@backstage/core-app-api" "^1.11.3" + "@backstage/core-compat-api" "^0.1.1" + "@backstage/core-components" "^0.13.10" + "@backstage/core-plugin-api" "^1.8.2" "@backstage/errors" "^1.2.3" - "@backstage/frontend-plugin-api" "^0.6.0" - "@backstage/plugin-catalog-react" "^1.10.0" - "@backstage/theme" "^0.5.1" + "@backstage/frontend-plugin-api" "^0.5.0" + "@backstage/plugin-catalog-react" "^1.9.3" + "@backstage/theme" "^0.5.0" "@backstage/types" "^1.1.1" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.61" - "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react" "^16.13.1 || ^17.0.0" react-use "^17.2.4" zen-observable "^0.10.0" @@ -4156,15 +3850,6 @@ "@emotion/styled" "^11.10.5" "@mui/material" "^5.12.2" -"@backstage/theme@^0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@backstage/theme/-/theme-0.5.2.tgz#7908830507a472fca8d6fa8b76362bdb08797bb7" - integrity sha512-9J+mx254+P0lQ0s//sGcdpoUVsr+WpeDYbqnGHIJxmjFGCCg2h7+255JlA+SE3AHCbpr8CYWI7ZseyzF0r9+BQ== - dependencies: - "@emotion/react" "^11.10.5" - "@emotion/styled" "^11.10.5" - "@mui/material" "^5.12.2" - "@backstage/types@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@backstage/types/-/types-1.1.1.tgz#c9ccb30357005e7fb5fa2ac140198059976eb076" @@ -4849,7 +4534,7 @@ teeny-request "^9.0.0" uuid "^8.0.0" -"@graphiql/react@^0.20.0", "@graphiql/react@^0.20.3": +"@graphiql/react@^0.20.0", "@graphiql/react@^0.20.2": version "0.20.3" resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.20.3.tgz#3de70153f51329da1d17a220510241320c87212e" integrity sha512-LHEiWQPABflTyRJZBZB50WSlrWER4RtlWg9XV1+D4yZQ3+6GbLM7X1zYf4D/TQ6AJB/vLZQHEnbhS0LuKcNqfA== @@ -5849,35 +5534,6 @@ resolved "https://registry.yarnpkg.com/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-3.2.0.tgz#c15791112db68dd9315d329d652b7e797f737655" integrity sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q== -"@node-saml/node-saml@^4.0.4": - version "4.0.5" - resolved "https://registry.yarnpkg.com/@node-saml/node-saml/-/node-saml-4.0.5.tgz#039e387095b54639b06df62b1b4a6d8941c6d907" - integrity sha512-J5DglElbY1tjOuaR1NPtjOXkXY5bpUhDoKVoeucYN98A3w4fwgjIOPqIGcb6cQsqFq2zZ6vTCeKn5C/hvefSaw== - dependencies: - "@types/debug" "^4.1.7" - "@types/passport" "^1.0.11" - "@types/xml-crypto" "^1.4.2" - "@types/xml-encryption" "^1.2.1" - "@types/xml2js" "^0.4.11" - "@xmldom/xmldom" "^0.8.6" - debug "^4.3.4" - xml-crypto "^3.0.1" - xml-encryption "^3.0.2" - xml2js "^0.5.0" - xmlbuilder "^15.1.1" - -"@node-saml/passport-saml@^4.0.4": - version "4.0.4" - resolved "https://registry.yarnpkg.com/@node-saml/passport-saml/-/passport-saml-4.0.4.tgz#dce5ca38828fb2e5f63d56d4c0aefa01ba3c1dbc" - integrity sha512-xFw3gw0yo+K1mzlkW15NeBF7cVpRHN/4vpjmBKzov5YFImCWh/G0LcTZ8krH3yk2/eRPc3Or8LRPudVJBjmYaw== - dependencies: - "@node-saml/node-saml" "^4.0.4" - "@types/express" "^4.17.14" - "@types/passport" "^1.0.11" - "@types/passport-strategy" "^0.2.35" - passport "^0.6.0" - passport-strategy "^1.0.0" - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -6314,10 +5970,10 @@ "@octokit/types" "^12.0.0" btoa-lite "^1.0.0" -"@octokit/openapi-types@^14.0.0": - version "14.0.0" - resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-14.0.0.tgz#949c5019028c93f189abbc2fb42f333290f7134a" - integrity sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw== +"@octokit/openapi-types@^12.11.0": + version "12.11.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-12.11.0.tgz#da5638d64f2b919bca89ce6602d059f1b52d3ef0" + integrity sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== "@octokit/openapi-types@^18.0.0": version "18.1.1" @@ -6469,12 +6125,12 @@ dependencies: "@octokit/openapi-types" "^19.1.0" -"@octokit/types@^8.0.0": - version "8.2.1" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-8.2.1.tgz#a6de091ae68b5541f8d4fcf9a12e32836d4648aa" - integrity sha512-8oWMUji8be66q2B9PmEIUyQm00VPDPun07umUWSaCwxmeaquFBro4Hcc3ruVoDo3zkQyZBlRvhIMEYS3pBhanw== +"@octokit/types@^6.8.2": + version "6.41.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.41.0.tgz#e58ef78d78596d2fb7df9c6259802464b5f84a04" + integrity sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg== dependencies: - "@octokit/openapi-types" "^14.0.0" + "@octokit/openapi-types" "^12.11.0" "@octokit/types@^9.0.0", "@octokit/types@^9.2.3": version "9.3.2" @@ -6540,11 +6196,6 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.7.0.tgz#b139c81999c23e3c8d3c0a7234480e945920fc40" integrity sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw== -"@opentelemetry/api@^1.4.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.8.0.tgz#5aa7abb48f23f693068ed2999ae627d2f7d902ec" - integrity sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w== - "@parcel/watcher@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b" @@ -6947,10 +6598,10 @@ dependencies: "@react-hookz/deep-equal" "^1.0.4" -"@remix-run/router@1.15.3": - version "1.15.3" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.15.3.tgz#d2509048d69dbb72d5389a14945339f1430b2d3c" - integrity sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w== +"@remix-run/router@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.15.0.tgz#461a952c2872dd82c8b2e9b74c4dfaff569123e2" + integrity sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ== "@repeaterjs/repeater@^3.0.4": version "3.0.5" @@ -7611,10 +7262,10 @@ resolved "https://registry.yarnpkg.com/@spotify/eslint-config-typescript/-/eslint-config-typescript-14.1.6.tgz#dffaf2eeffd0c879605497c3d6ab275d02053db1" integrity sha512-7TbP8nywFUMu73nNTv0NzCmmgNFvGWlRMFZ7lCiFS+UOmfyaiYpJXUHLXXmNQ+qB8/PMrtaznvCIYmus9tqI9Q== -"@spotify/prettier-config@^15.0.0": - version "15.0.0" - resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-15.0.0.tgz#3dcc94ffa9c3fad68a1e8430ccba57edc0131399" - integrity sha512-ex7bdst1STr97y/MUTgVxadxpEZy48XVQDwZrESYO07EONSRstQYBaQWGVo80jmJ9NXC+r4ZjUqjT2Gw524Nqg== +"@spotify/prettier-config@^12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-12.0.0.tgz#936ca5e977cfccbccd1731ab98b1f2bf65852b5d" + integrity sha512-64WWqE40U/WwWV8iIQBseTU+b2t+SdJSyQoCLdVPCKM9uf7KOjRivVwXe4KlWoV3y7duNSGuB2UgWhkXzscVmQ== "@stoplight/better-ajv-errors@1.0.3": version "1.0.3" @@ -8460,7 +8111,22 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/jest-dom@^6.4.2": +"@testing-library/jest-dom@^5.10.1": + version "5.17.0" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz#5e97c8f9a15ccf4656da00fecab505728de81e0c" + integrity sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg== + dependencies: + "@adobe/css-tools" "^4.0.1" + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + +"@testing-library/jest-dom@^6.0.0": version "6.4.2" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz#38949f6b63722900e2d75ba3c6d9bf8cffb3300e" integrity sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw== @@ -8474,7 +8140,7 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^14.2.1": +"@testing-library/react@^14.0.0", "@testing-library/react@^14.2.1": version "14.2.1" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.2.1.tgz#bf69aa3f71c36133349976a4a2da3687561d8310" integrity sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A== @@ -8503,13 +8169,13 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== -"@trendyol-js/openstack-swift-sdk@^0.0.7": - version "0.0.7" - resolved "https://registry.yarnpkg.com/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.7.tgz#45ba1dbfd1ccd3df263b25981a4d563943e1766d" - integrity sha512-N3jYUiqwNT4KajMPQJkFn6FrVCdFcnyRvphGHCdyfvxf3PxZbEDV2mDjLQ+qNTLWsDS3hEfE63KISOpM2Cuo6g== +"@trendyol-js/openstack-swift-sdk@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@trendyol-js/openstack-swift-sdk/-/openstack-swift-sdk-0.0.6.tgz#823fd1a310a0c1def770f065d0a71393b8e6d221" + integrity sha512-5OOjq6PQjDOavzWwepBl+YKrNWocAz2DoXuzTkfX4J4xRn+yBpkWX3ne7+jbuCDzRIyCaEhhacu3BpB5d2pzkg== dependencies: agentkeepalive "^4.1.4" - axios "^1.0.0" + axios "^0.21.1" axios-cached-dns-resolve "0.5.2" file-type "^16.5.4" @@ -8690,7 +8356,7 @@ "@types/node" "*" "@types/ssh2" "*" -"@types/dockerode@^3.3.0", "@types/dockerode@^3.3.24": +"@types/dockerode@^3.3.0": version "3.3.26" resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.26.tgz#e7f5f06e985ee045c9b9643fd9c34684deb80cd1" integrity sha512-/K+I9bGhRO2SvyIHisGeOsy/ypxnWLz8+Rde9S2tNNEKa3r91e0XMYIEq2D+kb7srm7xrmpAR0CDKfXoZOr4OA== @@ -8743,7 +8409,7 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express@*", "@types/express@^4.17.13", "@types/express@^4.17.14", "@types/express@^4.17.6": +"@types/express@*", "@types/express@^4.17.13", "@types/express@^4.17.6": version "4.17.21" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== @@ -8816,7 +8482,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.0.0": +"@types/jest@*", "@types/jest@^29.0.0": version "29.5.12" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== @@ -8887,7 +8553,12 @@ resolved "https://registry.yarnpkg.com/@types/lunr/-/lunr-2.3.7.tgz#378a98ecf7a9fafc42466f67f73173c34a6265a0" integrity sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A== -"@types/luxon@^3.0.0", "@types/luxon@^3.4.2": +"@types/luxon@^2.0.4": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-2.4.0.tgz#897d3abc23b68d78b69d76a12c21e01eb5adab95" + integrity sha512-oCavjEjRXuR6URJEtQm0eBdfsBiEcGBZbq21of8iGkeKxU1+1xgKuFPClaBZl2KB8ZZBSWlgk61tH6Mf+nvZVw== + +"@types/luxon@^3.0.0": version "3.4.2" resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== @@ -9006,15 +8677,7 @@ "@types/oauth" "*" "@types/passport" "*" -"@types/passport-strategy@^0.2.35": - version "0.2.38" - resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3" - integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA== - dependencies: - "@types/express" "*" - "@types/passport" "*" - -"@types/passport@*", "@types/passport@^1.0.11", "@types/passport@^1.0.3": +"@types/passport@*", "@types/passport@^1.0.3": version "1.0.16" resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.16.tgz#5a2918b180a16924c4d75c31254c31cdca5ce6cf" integrity sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A== @@ -9051,9 +8714,9 @@ integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== "@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": - version "18.2.22" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.22.tgz#d332febf0815403de6da8a97e5fe282cbe609bae" - integrity sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ== + version "18.2.21" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" + integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== dependencies: "@types/react" "*" @@ -9089,11 +8752,12 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": - version "18.2.73" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.73.tgz#0579548ad122660d99e00499d22e33b81e73ed94" - integrity sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA== + version "18.2.64" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" + integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== dependencies: "@types/prop-types" "*" + "@types/scheduler" "*" csstype "^3.0.2" "@types/request@^2.47.1", "@types/request@^2.48.8": @@ -9123,6 +8787,11 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/scheduler@*": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" + integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== + "@types/semver@^7.3.12", "@types/semver@^7.5.0": version "7.5.7" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.7.tgz#326f5fdda70d13580777bcaa1bc6fa772a5aef0e" @@ -9166,13 +8835,6 @@ dependencies: "@types/node" "*" -"@types/ssh2-streams@*": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz#e68795ba2bf01c76b93f9c9809e1f42f0eaaec5f" - integrity sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg== - dependencies: - "@types/node" "*" - "@types/ssh2@*": version "1.11.19" resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.11.19.tgz#4f2ec691b0674ea1590915fe5114a9aeae0eb41d" @@ -9180,14 +8842,6 @@ dependencies: "@types/node" "^18.11.18" -"@types/ssh2@^0.5.48": - version "0.5.52" - resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741" - integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg== - dependencies: - "@types/node" "*" - "@types/ssh2-streams" "*" - "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -9223,6 +8877,13 @@ dependencies: "@types/estree" "*" +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.9" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz#0fb1e6a0278d87b6737db55af5967570b67cb466" + integrity sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw== + dependencies: + "@types/jest" "*" + "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -9272,28 +8933,6 @@ dependencies: "@types/node" "*" -"@types/xml-crypto@^1.4.2": - version "1.4.6" - resolved "https://registry.yarnpkg.com/@types/xml-crypto/-/xml-crypto-1.4.6.tgz#6d1fd7d41c91554f2aed97c2ba273af0388fa5cf" - integrity sha512-A6jEW2FxLZo1CXsRWnZHUX2wzR3uDju2Bozt6rDbSmU/W8gkilaVbwFEVN0/NhnUdMVzwYobWtM6bU1QJJFb7Q== - dependencies: - "@types/node" "*" - xpath "0.0.27" - -"@types/xml-encryption@^1.2.1": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@types/xml-encryption/-/xml-encryption-1.2.4.tgz#0eceea58c82a89f62c0a2dc383a6461dfc2fe1ba" - integrity sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q== - dependencies: - "@types/node" "*" - -"@types/xml2js@^0.4.11": - version "0.4.14" - resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.14.tgz#5d462a2a7330345e2309c6b549a183a376de8f9a" - integrity sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ== - dependencies: - "@types/node" "*" - "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -9615,7 +9254,12 @@ fast-querystring "^1.1.1" tslib "^2.3.1" -"@xmldom/xmldom@^0.8.3", "@xmldom/xmldom@^0.8.5", "@xmldom/xmldom@^0.8.6", "@xmldom/xmldom@^0.8.8": +"@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.6", "@xmldom/xmldom@^0.7.9": + version "0.7.13" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3" + integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g== + +"@xmldom/xmldom@^0.8.3": version "0.8.10" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99" integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw== @@ -9912,30 +9556,30 @@ anymatch@^3.0.3, anymatch@~3.1.2: "app@link:packages/app": version "0.0.0" dependencies: - "@backstage/app-defaults" "^1.5.0" + "@backstage/app-defaults" "^1.4.7" "@backstage/catalog-model" "^1.4.3" "@backstage/cli" "^0.25.1" "@backstage/core-app-api" "^1.11.3" - "@backstage/core-components" "^0.14.0" + "@backstage/core-components" "^0.13.10" "@backstage/core-plugin-api" "^1.8.2" "@backstage/integration-react" "^1.1.23" - "@backstage/plugin-api-docs" "^0.11.2" + "@backstage/plugin-api-docs" "^0.10.3" "@backstage/plugin-catalog" "^1.16.1" "@backstage/plugin-catalog-common" "^1.0.20" "@backstage/plugin-catalog-graph" "^0.3.3" "@backstage/plugin-catalog-import" "^0.10.5" "@backstage/plugin-catalog-react" "^1.9.3" - "@backstage/plugin-github-actions" "^0.6.14" + "@backstage/plugin-github-actions" "^0.6.10" "@backstage/plugin-org" "^0.6.19" "@backstage/plugin-permission-react" "^0.4.19" "@backstage/plugin-scaffolder" "^1.17.1" - "@backstage/plugin-search" "^1.4.6" + "@backstage/plugin-search" "^1.4.5" "@backstage/plugin-search-react" "^1.7.5" - "@backstage/plugin-tech-radar" "^0.7.0" + "@backstage/plugin-tech-radar" "^0.6.12" "@backstage/plugin-techdocs" "^1.9.3" - "@backstage/plugin-techdocs-module-addons-contrib" "^1.1.5" + "@backstage/plugin-techdocs-module-addons-contrib" "^1.1.4" "@backstage/plugin-techdocs-react" "^1.1.15" - "@backstage/plugin-user-settings" "^0.8.1" + "@backstage/plugin-user-settings" "^0.8.0" "@backstage/theme" "^0.5.0" "@coder/backstage-plugin-coder" "0.0.0" "@coder/backstage-plugin-devcontainers-react" "0.0.0" @@ -9944,8 +9588,8 @@ anymatch@^3.0.3, anymatch@~3.1.2: history "^5.0.0" react "^18.0.2" react-dom "^18.0.2" - react-router "^6.22.3" - react-router-dom "^6.22.3" + react-router "^6.3.0" + react-router-dom "^6.3.0" react-use "^17.2.4" append-field@^1.0.0: @@ -9958,38 +9602,6 @@ append-field@^1.0.0: resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -archiver-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" - integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw== - dependencies: - glob "^7.1.4" - graceful-fs "^4.2.0" - lazystream "^1.0.0" - lodash.defaults "^4.2.0" - lodash.difference "^4.5.0" - lodash.flatten "^4.4.0" - lodash.isplainobject "^4.0.6" - lodash.union "^4.6.0" - normalize-path "^3.0.0" - readable-stream "^2.0.0" - -archiver-utils@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-3.0.4.tgz#a0d201f1cf8fce7af3b5a05aea0a337329e96ec7" - integrity sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw== - dependencies: - glob "^7.2.3" - graceful-fs "^4.2.0" - lazystream "^1.0.0" - lodash.defaults "^4.2.0" - lodash.difference "^4.5.0" - lodash.flatten "^4.4.0" - lodash.isplainobject "^4.0.6" - lodash.union "^4.6.0" - normalize-path "^3.0.0" - readable-stream "^3.6.0" - archiver-utils@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-4.0.1.tgz#66ad15256e69589a77f706c90c6dbcc1b2775d2a" @@ -10002,19 +9614,6 @@ archiver-utils@^4.0.1: normalize-path "^3.0.0" readable-stream "^3.6.0" -archiver@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/archiver/-/archiver-5.3.2.tgz#99991d5957e53bd0303a392979276ac4ddccf3b0" - integrity sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw== - dependencies: - archiver-utils "^2.1.0" - async "^3.2.4" - buffer-crc32 "^0.2.1" - readable-stream "^3.6.0" - readdir-glob "^1.1.2" - tar-stream "^2.2.0" - zip-stream "^4.1.0" - archiver@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/archiver/-/archiver-6.0.1.tgz#d56968d4c09df309435adb5a1bbfc370dae48133" @@ -10245,7 +9844,7 @@ astring@^1.8.1: resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg== -async-lock@^1.1.0, async-lock@^1.4.1: +async-lock@^1.1.0: version "1.4.1" resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f" integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ== @@ -10336,6 +9935,13 @@ axios-cached-dns-resolve@0.5.2: pino "^5.12.2" pino-pretty "^2.6.0" +axios@^0.21.1: + version "0.21.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== + dependencies: + follow-redirects "^1.14.0" + axios@^1.0.0, axios@^1.4.0, axios@^1.6.0: version "1.6.7" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" @@ -10485,37 +10091,10 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -bare-events@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.2.1.tgz#7b6d421f26a7a755e20bf580b727c84b807964c1" - integrity sha512-9GYPpsPFvrWBkelIhOhTWtkeZxVxZOdb3VnFTCzlOo3OjvmTvzLoZFUT8kNFACx0vJej6QPney1Cf9BvzCNE/A== - bare-events@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.2.0.tgz#a7a7263c107daf8b85adf0b64f908503454ab26e" - integrity sha512-Yyyqff4PIFfSuthCZqLlPISTWHmnQxoPuAvkmgzsJEmG3CesdIv6Xweayl0JkCZJSB2yYIdJyEz97tpxNhgjbg== - -bare-fs@^2.1.1: version "2.2.2" - resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-2.2.2.tgz#286bf54cc6f15f613bee6bb26f0c61c79fb14f06" - integrity sha512-X9IqgvyB0/VA5OZJyb5ZstoN62AzD7YxVGog13kkfYWYqJYcK0kcqLZ6TrmH5qr4/8//ejVcX4x/a0UvaogXmA== - dependencies: - bare-events "^2.0.0" - bare-os "^2.0.0" - bare-path "^2.0.0" - streamx "^2.13.0" - -bare-os@^2.0.0, bare-os@^2.1.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-2.2.1.tgz#c94a258c7a408ca6766399e44675136c0964913d" - integrity sha512-OwPyHgBBMkhC29Hl3O4/YfxW9n7mdTr2+SsO29XBWKKJsbgj3mnorDB80r5TiCQgQstgE5ga1qNYrpes6NvX2w== - -bare-path@^2.0.0, bare-path@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-2.1.0.tgz#830f17fd39842813ca77d211ebbabe238a88cb4c" - integrity sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw== - dependencies: - bare-os "^2.1.0" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.2.2.tgz#a98a41841f98b2efe7ecc5c5468814469b018078" + integrity sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ== base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" @@ -10626,13 +10205,13 @@ bn.js@^5.0.0, bn.js@^5.2.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@1.20.2: - version "1.20.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" - integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== +body-parser@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== dependencies: bytes "3.1.2" - content-type "~1.0.5" + content-type "~1.0.4" debug "2.6.9" depd "2.0.0" destroy "1.2.0" @@ -10640,7 +10219,7 @@ body-parser@1.20.2: iconv-lite "0.4.24" on-finished "2.4.1" qs "6.11.0" - raw-body "2.5.2" + raw-body "2.5.1" type-is "~1.6.18" unpipe "1.0.0" @@ -10787,7 +10366,7 @@ btoa-lite@^1.0.0: resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337" integrity sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA== -buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: +buffer-crc32@^0.2.1, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== @@ -11471,16 +11050,6 @@ component-emitter@^1.3.0: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== -compress-commons@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.2.tgz#6542e59cb63e1f46a8b21b0e06f9a32e4c8b06df" - integrity sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg== - dependencies: - buffer-crc32 "^0.2.13" - crc32-stream "^4.0.2" - normalize-path "^3.0.0" - readable-stream "^3.6.0" - compress-commons@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-5.0.1.tgz#e46723ebbab41b50309b27a0e0f6f3baed2d6590" @@ -11612,7 +11181,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -11723,6 +11292,11 @@ cookie@0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + cookie@0.6.0, cookie@^0.6.0, cookie@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" @@ -11830,14 +11404,6 @@ crc-32@^1.2.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== -crc32-stream@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-4.0.3.tgz#85dd677eb78fa7cad1ba17cc506a597d41fc6f33" - integrity sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw== - dependencies: - crc-32 "^1.2.0" - readable-stream "^3.4.0" - crc32-stream@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-5.0.0.tgz#a97d3a802c8687f101c27cc17ca5253327354720" @@ -12567,13 +12133,6 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" -docker-compose@^0.24.6: - version "0.24.7" - resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-0.24.7.tgz#66490508d5a08c221402108be8d5236d9567bb94" - integrity sha512-CdHl9n0S4+bl4i6MaxDQHNjqB1FdvuDirrDTzPKmdiMpheQqCjgsny0GZ2VhvN7qHTY0833lRlKWZgrkn1i6cg== - dependencies: - yaml "^2.2.2" - docker-modem@^3.0.0: version "3.0.8" resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-3.0.8.tgz#ef62c8bdff6e8a7d12f0160988c295ea8705e77a" @@ -12594,7 +12153,7 @@ docker-modem@^5.0.3: split-ca "^1.0.1" ssh2 "^1.15.0" -dockerode@^3.3.1, dockerode@^3.3.5: +dockerode@^3.3.1: version "3.3.5" resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-3.3.5.tgz#7ae3f40f2bec53ae5e9a741ce655fff459745629" integrity sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA== @@ -12626,7 +12185,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: version "0.5.16" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== @@ -13611,17 +13170,17 @@ express-session@^1.17.1: safe-buffer "5.2.1" uid-safe "~2.1.5" -express@^4.17.1, express@^4.17.3, express@^4.18.2, express@^4.19.2: - version "4.19.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" - integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== +express@^4.17.1, express@^4.17.3, express@^4.18.2: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.2" + body-parser "1.20.1" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.6.0" + cookie "0.5.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" @@ -13755,6 +13314,11 @@ fast-shallow-equal@^1.0.0: resolved "https://registry.yarnpkg.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== +fast-text-encoding@^1.0.0: + version "1.0.6" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" + integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== + fast-xml-parser@4.2.5: version "4.2.5" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz#a6747a09296a6cb34f2ae634019bf1738f3b421f" @@ -13950,10 +13514,10 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.15.4: - version "1.15.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" - integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== for-each@^0.3.3: version "0.3.3" @@ -14109,7 +13673,7 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@10.1.0, fs-extra@^10.0.0: +fs-extra@10.1.0, fs-extra@^10.0.0, fs-extra@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== @@ -14118,7 +13682,7 @@ fs-extra@10.1.0, fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^11.0.0, fs-extra@^11.1.0, fs-extra@^11.1.1, fs-extra@^11.2.0: +fs-extra@^11.1.0, fs-extra@^11.1.1, fs-extra@^11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== @@ -14219,6 +13783,16 @@ gauge@^4.0.3: strip-ansi "^6.0.1" wide-align "^1.1.5" +gaxios@^5.0.0, gaxios@^5.0.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-5.1.3.tgz#f7fa92da0fe197c846441e5ead2573d4979e9013" + integrity sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA== + dependencies: + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.6.9" + gaxios@^6.0.0, gaxios@^6.0.2, gaxios@^6.1.1: version "6.2.0" resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.2.0.tgz#4698976664ef63e47dbf3f61ec9320885fcc1ba1" @@ -14229,6 +13803,14 @@ gaxios@^6.0.0, gaxios@^6.0.2, gaxios@^6.1.1: is-stream "^2.0.0" node-fetch "^2.6.9" +gcp-metadata@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-5.3.0.tgz#6f45eb473d0cb47d15001476b48b663744d25408" + integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w== + dependencies: + gaxios "^5.0.0" + json-bigint "^1.0.0" + gcp-metadata@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-6.1.0.tgz#9b0dd2b2445258e7597f2024332d20611cbd6b8c" @@ -14292,7 +13874,7 @@ get-pkg-repo@^4.2.1: through2 "^2.0.0" yargs "^16.2.0" -get-port@5.1.1, get-port@^5.1.1: +get-port@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== @@ -14450,7 +14032,7 @@ glob@^10.2.2, glob@^10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7, glob@^7.2.0, glob@^7.2.3: +glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -14542,6 +14124,21 @@ globby@11.1.0, globby@^11.0.0, globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +google-auth-library@^8.0.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-8.9.0.tgz#15a271eb2ec35d43b81deb72211bd61b1ef14dd0" + integrity sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^5.0.0" + gcp-metadata "^5.3.0" + gtoken "^6.1.0" + jws "^4.0.0" + lru-cache "^6.0.0" + google-auth-library@^9.0.0, google-auth-library@^9.3.0: version "9.6.3" resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.6.3.tgz#add8935bc5b842a8e80f84fef2b5ed9febb41d48" @@ -14572,6 +14169,13 @@ google-gax@^4.0.4: retry-request "^7.0.0" uuid "^9.0.1" +google-p12-pem@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-4.0.1.tgz#82841798253c65b7dc2a4e5fe9df141db670172a" + integrity sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ== + dependencies: + node-forge "^1.3.1" + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -14606,12 +14210,12 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -graphiql@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.1.1.tgz#77c64355b122662ba8c3e1a3cdf3f5c0251c6cad" - integrity sha512-FMNa981Wj8JBJJRTdryNyrVteigS8B7q+Q1fh1rW4IsFPaXNIs1VMs8kwqIZ8zERj4Fc64Ea750g3n6r2w9Zcg== +graphiql@3.0.10: + version "3.0.10" + resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.0.10.tgz#68a7d894ef4ac1d13cc10e2ccfe4128d0556cd29" + integrity sha512-xgRFCg0mgIyca8keWkmBFA3knh9exDg53SxqFh96ewoMWYLeziqc0xIGFe2L/As8Aw1u5pFZcW913HwX3IXztw== dependencies: - "@graphiql/react" "^0.20.3" + "@graphiql/react" "^0.20.2" "@graphiql/toolkit" "^0.9.1" graphql-language-service "^5.2.0" markdown-it "^12.2.0" @@ -14665,6 +14269,15 @@ graphql@^16.0.0, graphql@^16.8.1: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw== +gtoken@^6.1.0: + version "6.1.2" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-6.1.2.tgz#aeb7bdb019ff4c3ba3ac100bbe7b6e74dce0e8bc" + integrity sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ== + dependencies: + gaxios "^5.0.1" + google-p12-pem "^4.0.0" + jws "^4.0.0" + gtoken@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-7.1.0.tgz#d61b4ebd10132222817f7222b1e6064bd463fc26" @@ -16286,10 +15899,10 @@ jmespath@^0.15.0: resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" integrity sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w== -jose@^4.15.5, jose@^4.6.0: - version "4.15.5" - resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.5.tgz#6475d0f467ecd3c630a1b5dadd2735a7288df706" - integrity sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg== +jose@^4.15.4, jose@^4.6.0: + version "4.15.4" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.4.tgz#02a9a763803e3872cf55f29ecef0dfdcc218cc03" + integrity sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ== jose@^5.0.0: version "5.2.3" @@ -16773,6 +16386,11 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +jwt-decode@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + keyv@^4.0.0, keyv@^4.5.2, keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -17142,16 +16760,6 @@ lodash.defaults@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== -lodash.difference@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" - integrity sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA== - -lodash.flatten@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" - integrity sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g== - lodash.flattendeep@^4.0.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -17222,11 +16830,6 @@ lodash.topath@^4.5.2: resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009" integrity sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg== -lodash.union@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" - integrity sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw== - lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -18377,9 +17980,9 @@ mysql2@^2.2.5: sqlstring "^2.3.2" mysql2@^3.0.0: - version "3.9.2" - resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.9.2.tgz#567343581f9742032598b6c15bd7aa65d2f7d4af" - integrity sha512-3Cwg/UuRkAv/wm6RhtPE5L7JlPB877vwSF6gfLAS68H+zhH+u5oa3AieqEd0D0/kC3W7qIhYbH419f7O9i/5nw== + version "3.9.3" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.9.3.tgz#72a5e0c90d78ec2d8f9846e83727067c0cc8c25e" + integrity sha512-+ZaoF0llESUy7BffccHG+urErHcWPZ/WuzYAA9TEeLaDYyke3/3D+VQDzK9xzRnXpd0eMtRf0WNOeo4Q1Baung== dependencies: denque "^2.1.0" generate-function "^2.3.1" @@ -18531,7 +18134,7 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.6.9, node-fetch@^2.7.0: +node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.6.9: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -18980,12 +18583,12 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -octokit-plugin-create-pull-request@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/octokit-plugin-create-pull-request/-/octokit-plugin-create-pull-request-5.1.1.tgz#99ee3cf0aa170563d3ea32e86c5ac3be3c20c20d" - integrity sha512-kHbo3bB9pkzQGNVJPTv6hkpFbXL/s2tbrQm+7uqtS46C6c6R/BDgFvk1nEPWBczXvftwinb33pLWXTKH10Rx1Q== +octokit-plugin-create-pull-request@^3.10.0: + version "3.13.1" + resolved "https://registry.yarnpkg.com/octokit-plugin-create-pull-request/-/octokit-plugin-create-pull-request-3.13.1.tgz#fe6f15375cc4592dfa25730778c090f9f2721979" + integrity sha512-nsWZRn7NrqZvqGl3E0VcDDyyS/4xbNNvwWM2yk65TViLdEBoOhIU5SqKdfqANa+WPwv5DwHsO3T10DK1qMg72w== dependencies: - "@octokit/types" "^8.0.0" + "@octokit/types" "^6.8.2" octokit@^3.0.0: version "3.1.2" @@ -19096,12 +18699,12 @@ openapi3-ts@^3.1.2: dependencies: yaml "^2.2.1" -openid-client@^5.2.1, openid-client@^5.3.0, openid-client@^5.5.0: - version "5.6.5" - resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.5.tgz#c149ad07b9c399476dc347097e297bbe288b8b00" - integrity sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w== +openid-client@^5.2.1, openid-client@^5.3.0: + version "5.6.4" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.4.tgz#b2c25e6d5338ba3ce00e04341bb286798a196177" + integrity sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA== dependencies: - jose "^4.15.5" + jose "^4.15.4" lru-cache "^6.0.0" object-hash "^2.2.0" oidc-token-hash "^5.0.3" @@ -19516,20 +19119,24 @@ passport-onelogin-oauth@^0.0.1: pkginfo "0.2.x" uid2 "0.0.3" +passport-saml@^3.1.2: + version "3.2.4" + resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-3.2.4.tgz#e8e9523f954988a3a47d12e425d7fa0f20a74dc9" + integrity sha512-JSgkFXeaexLNQh1RrOvJAgjLnZzH/S3HbX/mWAk+i7aulnjqUe7WKnPl1NPnJWqP7Dqsv0I2Xm6KIFHkftk0HA== + dependencies: + "@xmldom/xmldom" "^0.7.6" + debug "^4.3.2" + passport-strategy "^1.0.0" + xml-crypto "^2.1.3" + xml-encryption "^2.0.0" + xml2js "^0.4.23" + xmlbuilder "^15.1.1" + passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== -passport@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/passport/-/passport-0.6.0.tgz#e869579fab465b5c0b291e841e6cc95c005fac9d" - integrity sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug== - dependencies: - passport-strategy "1.x.x" - pause "0.0.1" - utils-merge "^1.0.1" - passport@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/passport/-/passport-0.7.0.tgz#3688415a59a48cf8068417a8a8092d4492ca3a05" @@ -20241,14 +19848,6 @@ prom-client@^14.0.1: dependencies: tdigest "^0.1.1" -prom-client@^15.0.0: - version "15.1.0" - resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.0.tgz#816a4a2128da169d0471093baeccc6d2f17a4613" - integrity sha512-cCD7jLTqyPdjEPBo/Xk4Iu8jxjuZgZJ3e/oET3L+ZwOuap/7Cw3dH/TJSsZKs1TQLZ2IHpIlRAKw82ef06kmMw== - dependencies: - "@opentelemetry/api" "^1.4.0" - tdigest "^0.1.1" - promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -20291,22 +19890,6 @@ prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.6.2, object-assign "^4.1.1" react-is "^16.13.1" -proper-lockfile@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" - integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== - dependencies: - graceful-fs "^4.2.4" - retry "^0.12.0" - signal-exit "^3.0.2" - -properties-reader@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/properties-reader/-/properties-reader-2.3.0.tgz#f3ab84224c9535a7a36e011ae489a79a13b472b2" - integrity sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw== - dependencies: - mkdirp "^1.0.4" - property-expr@^2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" @@ -20524,7 +20107,17 @@ rate-limiter-flexible@^4.0.0: resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-4.0.1.tgz#79b0ce111abe9c5da41d6fddf7cca93cedd3a8fc" integrity sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ== -raw-body@2.5.2, raw-body@^2.4.1: +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +raw-body@^2.4.1: version "2.5.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== @@ -20663,11 +20256,6 @@ react-idle-timer@5.6.2: resolved "https://registry.yarnpkg.com/react-idle-timer/-/react-idle-timer-5.6.2.tgz#0342b381ca26ea46e8232dbdc7f2b948bc4ddb0d" integrity sha512-X7zjDv7duCopQ4v3X2Gun8QunvYplPWkvW2y7suDSREu1vQRQ0mr1ESv325QoJuvSIE5QCSbLaJlrbbooNaUNg== -react-idle-timer@5.7.2: - version "5.7.2" - resolved "https://registry.yarnpkg.com/react-idle-timer/-/react-idle-timer-5.7.2.tgz#f506db28a86645dd1b87987116501703e512142b" - integrity sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ== - react-immutable-proptypes@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz#cce96d68cc3c18e89617cbf3092d08e35126af4a" @@ -20765,20 +20353,20 @@ react-remove-scroll@2.5.5: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" -react-router-dom@^6.22.3: - version "6.22.3" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.22.3.tgz#9781415667fd1361a475146c5826d9f16752a691" - integrity sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw== +react-router-dom@^6.3.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.22.0.tgz#177c8bd27146decbb991eafb5df159f7a9f70035" + integrity sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag== dependencies: - "@remix-run/router" "1.15.3" - react-router "6.22.3" + "@remix-run/router" "1.15.0" + react-router "6.22.0" -react-router@6.22.3, react-router@^6.22.3: - version "6.22.3" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.22.3.tgz#9d9142f35e08be08c736a2082db5f0c9540a885e" - integrity sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ== +react-router@6.22.0, react-router@^6.3.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.22.0.tgz#a22b44851a79dafc6b944cb418db3e80622b9be1" + integrity sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg== dependencies: - "@remix-run/router" "1.15.3" + "@remix-run/router" "1.15.0" react-side-effect@^2.1.0: version "2.1.2" @@ -20959,7 +20547,7 @@ readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stre string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -21365,7 +20953,7 @@ rimraf@^2.6.3: dependencies: glob "^7.1.3" -rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -22083,15 +21671,7 @@ sqlstring@^2.3.2: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== -ssh-remote-port-forward@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" - integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== - dependencies: - "@types/ssh2" "^0.5.48" - ssh2 "^1.4.0" - -ssh2@^1.11.0, ssh2@^1.15.0, ssh2@^1.4.0: +ssh2@^1.11.0, ssh2@^1.15.0: version "1.15.0" resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.15.0.tgz#2f998455036a7f89e0df5847efb5421748d9871b" integrity sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw== @@ -22265,7 +21845,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -streamx@^2.13.0: +streamx@^2.15.0: version "2.16.1" resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.16.1.tgz#2b311bd34832f08aa6bb4d6a80297c9caef89614" integrity sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ== @@ -22275,16 +21855,6 @@ streamx@^2.13.0: optionalDependencies: bare-events "^2.2.0" -streamx@^2.15.0: - version "2.15.8" - resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.15.8.tgz#5471145b54ee43b5088877023d8d0a2a77f95d8d" - integrity sha512-6pwMeMY/SuISiRsuS8TeIrAzyFbG5gGPHFQsYjUr/pbBadaL1PCWmzKw+CHZSwainfvcF6Si6cVLq4XTEwswFQ== - dependencies: - fast-fifo "^1.1.0" - queue-tick "^1.0.1" - optionalDependencies: - bare-events "^2.2.0" - strict-event-emitter@^0.2.4: version "0.2.8" resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz#b4e768927c67273c14c13d20e19d5e6c934b47ca" @@ -22320,7 +21890,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22394,7 +21973,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22408,6 +21987,13 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -22705,17 +22291,6 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.1.4" -tar-fs@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.5.tgz#f954d77767e4e6edf973384e1eb95f8f81d64ed9" - integrity sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg== - dependencies: - pump "^3.0.0" - tar-stream "^3.1.5" - optionalDependencies: - bare-fs "^2.1.1" - bare-path "^2.1.0" - tar-fs@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" @@ -22726,7 +22301,7 @@ tar-fs@~2.0.1: pump "^3.0.0" tar-stream "^2.0.0" -tar-stream@^2.0.0, tar-stream@^2.1.4, tar-stream@^2.2.0, tar-stream@~2.2.0: +tar-stream@^2.0.0, tar-stream@^2.1.4, tar-stream@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== @@ -22737,7 +22312,7 @@ tar-stream@^2.0.0, tar-stream@^2.1.4, tar-stream@^2.2.0, tar-stream@~2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar-stream@^3.0.0, tar-stream@^3.1.5: +tar-stream@^3.0.0: version "3.1.7" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== @@ -22828,27 +22403,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -testcontainers@^10.0.0: - version "10.7.2" - resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.7.2.tgz#619e93200dd47f174b307b40fa830cf023b74c25" - integrity sha512-7d+LVd/4YKp/cutiVMLL5cnj/8p8oYELAVRRyNUM4FyUDz1OLQuwW868nDl7Vd1ZAQxzGeCR+F86FlR9Yw9fMA== - dependencies: - "@balena/dockerignore" "^1.0.2" - "@types/dockerode" "^3.3.24" - archiver "^5.3.2" - async-lock "^1.4.1" - byline "^5.0.0" - debug "^4.3.4" - docker-compose "^0.24.6" - dockerode "^3.3.5" - get-port "^5.1.1" - node-fetch "^2.7.0" - proper-lockfile "^4.1.2" - properties-reader "^2.3.0" - ssh-remote-port-forward "^1.0.4" - tar-fs "^3.0.5" - tmp "^0.2.1" - text-extensions@^1.0.0: version "1.9.0" resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.9.0.tgz#1853e45fee39c945ce6f6c36b2d659b5aabc2a26" @@ -22864,11 +22418,6 @@ text-table@0.2.0, text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -textextensions@^5.16.0: - version "5.16.0" - resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-5.16.0.tgz#57dd60c305019bba321e848b1fdf0f99bfa59ec1" - integrity sha512-7D/r3s6uPZyU//MCYrX6I14nzauDwJ5CxazouuRGNuvSCihW87ufN6VLoROLCrHg6FblLuJrT6N2BVaPVzqElw== - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -22942,18 +22491,11 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" -tmp@^0.2.1: +tmp@~0.2.1: version "0.2.3" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== -tmp@~0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" - integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== - dependencies: - rimraf "^3.0.0" - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -23741,7 +23283,7 @@ uuid@^3.3.2, uuid@^3.4.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== -uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: +uuid@^8.0.0, uuid@^8.2.0, uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== @@ -23984,9 +23526,9 @@ webidl-conversions@^7.0.0: integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== webpack-dev-middleware@^5.3.1: - version "5.3.4" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517" - integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q== + version "5.3.3" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" + integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA== dependencies: colorette "^2.0.10" memfs "^3.4.3" @@ -24255,7 +23797,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -24273,6 +23815,15 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -24355,20 +23906,20 @@ xml-but-prettier@^1.0.1: dependencies: repeat-string "^1.5.2" -xml-crypto@^3.0.1: - version "3.2.0" - resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-3.2.0.tgz#a9debab572c8e895cff5fb351a8d8be3f6e1962e" - integrity sha512-qVurBUOQrmvlgmZqIVBqmb06TD2a/PpEUfFPgD7BuBfjmoH4zgkqaWSIJrnymlCvM2GGt9x+XtJFA+ttoAufqg== +xml-crypto@^2.1.3: + version "2.1.5" + resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-2.1.5.tgz#e201ee51dca18dd9ae158ac101b6e995c983dca8" + integrity sha512-xOSJmGFm+BTXmaPYk8pPV3duKo6hJuZ5niN4uMzoNcTlwYs0jAu/N3qY+ud9MhE4N7eMRuC1ayC7Yhmb7MmAWg== dependencies: - "@xmldom/xmldom" "^0.8.8" + "@xmldom/xmldom" "^0.7.9" xpath "0.0.32" -xml-encryption@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-3.0.2.tgz#d3cb67d97cdd9673313a42cc0d7fa43ff0886c21" - integrity sha512-VxYXPvsWB01/aqVLd6ZMPWZ+qaj0aIdF+cStrVJMcFj3iymwZeI0ABzB3VqMYv48DkSpRhnrXqTUkR34j+UDyg== +xml-encryption@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-2.0.0.tgz#d4e1eb3ec1f2c5d2a2a0a6e23d199237e8b4bf83" + integrity sha512-4Av83DdvAgUQQMfi/w8G01aJshbEZP9ewjmZMpS9t3H+OCZBDvyK4GJPnHGfWiXlArnPbYvR58JB9qF2x9Ds+Q== dependencies: - "@xmldom/xmldom" "^0.8.5" + "@xmldom/xmldom" "^0.7.0" escape-html "^1.0.3" xpath "0.0.32" @@ -24382,6 +23933,14 @@ xml-name-validator@^4.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== +xml2js@^0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + xml2js@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7" @@ -24410,11 +23969,6 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xpath@0.0.27: - version "0.0.27" - resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92" - integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== - xpath@0.0.32: version "0.0.32" resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" @@ -24553,15 +24107,6 @@ zenscroll@^4.0.2: resolved "https://registry.yarnpkg.com/zenscroll/-/zenscroll-4.0.2.tgz#e8d5774d1c0738a47bcfa8729f3712e2deddeb25" integrity sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg== -zip-stream@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.1.tgz#1337fe974dbaffd2fa9a1ba09662a66932bd7135" - integrity sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ== - dependencies: - archiver-utils "^3.0.4" - compress-commons "^4.1.2" - readable-stream "^3.6.0" - zip-stream@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-5.0.1.tgz#cf3293bba121cad98be2ec7f05991d81d9f18134" From b19c08e822706323e1ebe9496e7408bd9a4660e2 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 28 Mar 2024 17:48:41 -0400 Subject: [PATCH 09/33] refactor: clean up and consolidate config values in codebase (#29) * fix: fix hover behavior for last list item * fix: shrink default max height for container * fix: ensure divider bar appears when there is overflow * refactor: add workspaceCreationLink prop to context provider * refactor: split Placeholder into separate component * chore: finish cta button * fix: make sure button only appears when loading is finished * docs: remove bad comment * chore: add explicit return type to useCoderAppConfig for clarity * refactor: consolidate and decouple type definitions * refactor: move dynamic entity config logic * refactor: update references for workspaces config * refactor: centralize creationUrl logic * refactor: rename useCoderEntityConfig to useCoderWorkspacesConfig * refactor: rename old useCoderWorkspaces to useCoderWorkspacesQuery * fix: update typo in test case * fix: update test logic to account for creationUrl * fix: update query logic to account for always-defined workspacesConfig * docs: fix typo in comment * refactor: clean up how mock data is defined * fix: make logic for showing reminder more airtight * refactor: split DataReminder into separate file * refactor: simplify API for useCoderWorkspacesQuery * fix: make sure data reminder only shows when appropriate * fix: delete stale DataReminder file * docs: update type definitions * docs: update hook/type docs to reflect new APIs * docs: fix typo * chore: try removing react-use dependency to make CI happy --- .../backstage-plugin-coder/docs/components.md | 18 +- plugins/backstage-plugin-coder/docs/hooks.md | 63 +++--- plugins/backstage-plugin-coder/docs/types.md | 31 +-- plugins/backstage-plugin-coder/package.json | 1 - plugins/backstage-plugin-coder/src/api.ts | 10 +- .../CoderProvider/CoderAppConfigProvider.tsx | 31 ++- .../CreateWorkspaceLink.tsx | 4 +- .../CoderWorkspacesCard/HeaderRow.tsx | 4 +- .../CoderWorkspacesCard/Placeholder.tsx | 4 +- .../components/CoderWorkspacesCard/Root.tsx | 89 ++------- .../CoderWorkspacesCard/WorkspacesList.tsx | 4 +- .../src/hooks/useCoderEntityConfig.ts | 121 ------------ ...st.ts => useCoderWorkspacesConfig.test.ts} | 75 ++++--- .../src/hooks/useCoderWorkspacesConfig.ts | 185 ++++++++++++++++++ ...est.ts => useCoderWorkspacesQuery.test.ts} | 15 +- ...rkspaces.ts => useCoderWorkspacesQuery.ts} | 24 ++- plugins/backstage-plugin-coder/src/plugin.ts | 4 +- .../src/testHelpers/mockBackstageData.ts | 80 ++++---- .../package.json | 3 +- 19 files changed, 394 insertions(+), 372 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.ts rename plugins/backstage-plugin-coder/src/hooks/{useCoderEntityConfig.test.ts => useCoderWorkspacesConfig.test.ts} (62%) create mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts rename plugins/backstage-plugin-coder/src/hooks/{useCoderWorkspaces.test.ts => useCoderWorkspacesQuery.test.ts} (78%) rename plugins/backstage-plugin-coder/src/hooks/{useCoderWorkspaces.ts => useCoderWorkspacesQuery.ts} (51%) diff --git a/plugins/backstage-plugin-coder/docs/components.md b/plugins/backstage-plugin-coder/docs/components.md index e37aff20..5b555915 100644 --- a/plugins/backstage-plugin-coder/docs/components.md +++ b/plugins/backstage-plugin-coder/docs/components.md @@ -38,8 +38,11 @@ declare function CoderAuthWrapper(props: Props): JSX.Element; ```tsx function YourComponent() { // This query requires authentication - const query = useCoderWorkspaces('owner:lil-brudder'); - return

{query.isLoading ? 'Loading' : 'Not loading'}

; + const queryState = useCoderWorkspacesQuery({ + coderQuery: 'owner:lil-brudder', + }); + + return

{queryState.isLoading ? 'Loading' : 'Not loading'}

; } @@ -79,7 +82,7 @@ declare function CoderErrorBoundary(props: Props): JSX.Element; function YourComponent() { // Pretend that there is an issue with this hook, and that it will always // throw an error - const config = useCoderEntityConfig(); + const config = useCoderWorkspacesConfig(); return

Will never reach this code

; } @@ -123,10 +126,13 @@ The type of `QueryClient` comes from [Tanstack Router v4](https://tanstack.com/q ```tsx function YourComponent() { - const query = useCoderWorkspaces('owner:brennan-lee-mulligan'); + const queryState = useCoderWorkspacesQuery({ + coderQuery: 'owner:brennan-lee-mulligan', + }); + return (
    - {query.data?.map(workspace => ( + {queryState.data?.map(workspace => (
  • {workspace.owner_name}
  • ))}
@@ -396,8 +402,8 @@ type WorkspacesCardContext = { queryFilter: string; onFilterChange: (newFilter: string) => void; workspacesQuery: UseQueryResult; + workspacesConfig: CoderWorkspacesConfig; headerId: string; - entityConfig: CoderEntityConfig | undefined; }; declare function Root(props: Props): JSX.Element; diff --git a/plugins/backstage-plugin-coder/docs/hooks.md b/plugins/backstage-plugin-coder/docs/hooks.md index 0b9865a9..282fba6f 100644 --- a/plugins/backstage-plugin-coder/docs/hooks.md +++ b/plugins/backstage-plugin-coder/docs/hooks.md @@ -4,27 +4,33 @@ This is the main documentation page for the Coder plugin's React hooks. ## Hook list -- [`useCoderEntityConfig`](#useCoderEntityConfig) -- [`useCoderWorkspaces`](#useCoderWorkspaces) +- [`useCoderWorkspacesConfig`](#useCoderWorkspacesConfig) +- [`useCoderWorkspacesQuery`](#useCoderWorkspacesquery) - [`useWorkspacesCardContext`](#useWorkspacesCardContext) -## `useCoderEntityConfig` +## `useCoderWorkspacesConfig` -This hook gives you access to compiled [`CoderEntityConfig`](./types.md#coderentityconfig) data. +This hook gives you access to compiled [`CoderWorkspacesConfig`](./types.md#coderworkspacesconfig) data. ### Type signature ```tsx -declare function useCoderEntityConfig(): CoderEntityConfig; +type UseCoderWorkspacesConfigOptions = Readonly<{ + readEntityData?: boolean; +}>; + +declare function useCoderWorkspacesConfig( + options: UseCoderWorkspacesConfigOptions, +): CoderWorkspacesConfig; ``` -[Type definition for `CoderEntityConfig`](./types.md#coderentityconfig) +[Type definition for `CoderWorkspacesConfig`](./types.md#coderWorkspacesconfig) ### Example usage ```tsx function YourComponent() { - const config = useCoderEntityConfig(); + const config = useCoderWorkspacesConfig(); return

Your repo URL is {config.repoUrl}

; } @@ -52,30 +58,29 @@ const serviceEntityPage = ( ### Throws - Will throw an error if called outside a React component -- Will throw an error if called outside an `EntityLayout` (or any other Backstage component that exposes `Entity` data via React Context) +- Will throw if the value of the `readEntityData` property input changes across re-renders ### Notes -- The type definition for `CoderEntityConfig` [can be found here](./types.md#coderentityconfig). That section also includes info on the heuristic used for compiling the data +- The type definition for `CoderWorkspacesConfig` [can be found here](./types.md#coderworkspacesconfig). That section also includes info on the heuristic used for compiling the data +- The value of `readEntityData` determines the "mode" that the workspace operates in. If the value is `false`/`undefined`, the component will act as a general list of workspaces that isn't aware of Backstage APIs. If the value is `true`, the hook will also read Backstage data during the compilation step. - The hook tries to ensure that the returned value maintains a stable memory reference as much as possible, if you ever need to use that value in other React hooks that use dependency arrays (e.g., `useEffect`, `useCallback`) -## `useCoderWorkspaces` +## `useCoderWorkspacesQuery` This hook gives you access to all workspaces that match a given query string. If -[`repoConfig`](#usecoderentityconfig) is defined via `options`, the workspaces returned will be filtered down further to only those that match the the repo. +[`workspacesConfig`](#usecoderworkspacesconfig) is defined via `options`, and that config has a defined `repoUrl`, the workspaces returned will be filtered down further to only those that match the the repo. ### Type signature ```ts -type UseCoderWorkspacesOptions = Readonly< - Partial<{ - repoConfig: CoderEntityConfig; - }> ->; - -declare function useCoderEntityConfig( - coderQuery: string, - options?: UseCoderWorkspacesOptions, +type UseCoderWorkspacesQueryOptions = Readonly<{ + coderQuery: string; + workspacesConfig?: CoderWorkspacesConfig; +}>; + +declare function useCoderWorkspacesConfig( + options: UseCoderWorkspacesQueryOptions, ): UseQueryResult; ``` @@ -83,19 +88,16 @@ declare function useCoderEntityConfig( ```tsx function YourComponent() { - const entityConfig = useCoderEntityConfig(); const [filter, setFilter] = useState('owner:me'); - - const query = useCoderWorkspaces(filter, { - repoConfig: entityConfig, - }); + const workspacesConfig = useCoderWorkspacesConfig({ readEntityData: true }); + const queryState = useCoderWorkspacesQuery({ filter, workspacesConfig }); return ( <> - {query.isLoading && } - {query.isError && } + {queryState.isLoading && } + {queryState.isError && } - {query.data?.map(workspace => ( + {queryState.data?.map(workspace => (
  1. {workspace.name}
@@ -127,7 +129,8 @@ const coderAppConfig: CoderAppConfig = { - The underlying query will not be enabled if: 1. The user is not currently authenticated (We recommend wrapping your component inside [`CoderAuthWrapper`](./components.md#coderauthwrapper) to make these checks easier) 2. If `repoConfig` is passed in via `options`: when the value of `coderQuery` is an empty string -- `CoderEntityConfig` is the return type of [`useCoderEntityConfig`](#usecoderentityconfig) +- The `workspacesConfig` property is the return type of [`useCoderWorkspacesConfig`](#usecoderworkspacesconfig) + - The only way to get automatically-filtered results is by (1) passing in a workspaces config value, and (2) ensuring that config has a `repoUrl` property of type string (it can sometimes be `undefined`, depending on built-in Backstage APIs). ## `useWorkspacesCardContext` @@ -140,8 +143,8 @@ type WorkspacesCardContext = Readonly<{ queryFilter: string; onFilterChange: (newFilter: string) => void; workspacesQuery: UseQueryResult; + workspacesConfig: CoderWorkspacesConfig; headerId: string; - entityConfig: CoderEntityConfig | undefined; }>; declare function useWorkspacesCardContext(): WorkspacesCardContext; diff --git a/plugins/backstage-plugin-coder/docs/types.md b/plugins/backstage-plugin-coder/docs/types.md index 4a0fa72a..6caf7cd9 100644 --- a/plugins/backstage-plugin-coder/docs/types.md +++ b/plugins/backstage-plugin-coder/docs/types.md @@ -6,12 +6,12 @@ ```tsx // Type intersection - type CustomType = CoderEntityConfig & { + type CustomType = CoderWorkspacesConfig & { customProperty: boolean; }; // Interface extension - new interface must have a different name - interface CustomInterface extends CoderEntityConfig { + interface CustomInterface extends CoderWorkspacesConfig { customProperty: string; } ``` @@ -19,7 +19,7 @@ ## Types directory - [`CoderAppConfig`](#coderappconfig) -- [`CoderEntityConfig`](#coderentityconfig) +- [`CoderWorkspacesConfig`](#coderworkspacesconfig) - [`Workspace`](#workspace) - [`WorkspaceResponse`](#workspaceresponse) @@ -57,22 +57,23 @@ See example for [`CoderProvider`](./components.md#coderprovider) - `templateName` refers to the name of the Coder template that you wish to use as default for creating workspaces - If `mode` is not specified, the plugin will default to a value of `manual` - `repoUrlParamKeys` is defined as a non-empty array – there must be at least one element inside it. -- For more info on how this type is used within the plugin, see [`CoderEntityConfig`](./types.md#coderentityconfig) and [`useCoderEntityConfig`](./hooks.md#usecoderentityconfig) +- For more info on how this type is used within the plugin, see [`CoderWorkspacesConfig`](./types.md#coderworkspacesconfig) and [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) -## `CoderEntityConfig` +## `CoderWorkspacesConfig` -Represents the result of compiling Coder plugin configuration data. All data will be compiled from the following sources: +Represents the result of compiling Coder plugin configuration data. The main source for this type is [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig). All data will be compiled from the following sources: -1. The [`CoderAppConfig`](#coderappconfig) passed to [`CoderProvider`](./components.md#coderprovider) +1. The [`CoderAppConfig`](#coderappconfig) passed to [`CoderProvider`](./components.md#coderprovider). This acts as the "baseline" set of values. 2. The entity-specific fields for a given repo's `catalog-info.yaml` file 3. The entity's location metadata (corresponding to the repo) ### Type definition ```tsx -type CoderEntityConfig = Readonly<{ +type CoderWorkspacesConfig = Readonly<{ mode: 'manual' | 'auto'; params: Record; + creationUrl: string; repoUrl: string | undefined; repoUrlParamKeys: [string, ...string[]][]; templateName: string; @@ -90,7 +91,7 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', + templateName: 'devcontainers-a', mode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { @@ -112,7 +113,7 @@ spec: lifecycle: unknown owner: pms coder: - templateName: 'devcontainers' + templateName: 'devcontainers-b' mode: 'auto' params: repo: 'custom' @@ -122,7 +123,7 @@ spec: Your output will look like this: ```tsx -const config: CoderEntityConfig = { +const config: CoderWorkspacesConfig = { mode: 'auto', params: { repo: 'custom', @@ -130,9 +131,14 @@ const config: CoderEntityConfig = { custom_repo: 'https://github.com/Parkreiner/python-project/', repo_url: 'https://github.com/Parkreiner/python-project/', }, - repoUrl: 'https://github.com/Parkreiner/python-project/', repoUrlParamKeys: ['custom_repo', 'repo_url'], templateName: 'devcontainers', + repoUrl: 'https://github.com/Parkreiner/python-project/', + + // Other URL parameters will be included in real code + // but were stripped out for this example + creationUrl: + 'https://dev.coder.com/templates/devcontainers-b/workspace?mode=auto', }; ``` @@ -146,6 +152,7 @@ const config: CoderEntityConfig = { 3. Go through all properties parsed from `catalog-info.yaml` and inject those. If the properties are already defined, overwrite them 4. Grab the repo URL from the entity's location fields. 5. For each key in `CoderAppConfig`'s `workspaces.repoUrlParamKeys` property, take that key, and inject it as a key-value pair, using the URL as the value. If the key already exists, always override it with the URL + 6. Use the Coder access URL and the properties defined during the previous steps to create the URL for creating new workspaces, and then inject that. ## `Workspace` diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index b65a89a7..a618c639 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,7 +41,6 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", - "react-use": "^17.2.4", "valibot": "^0.28.1" }, "peerDependencies": { diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index fde7ce53..02dadbe4 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -1,7 +1,7 @@ import { parse } from 'valibot'; import { type UseQueryOptions } from '@tanstack/react-query'; -import { CoderEntityConfig } from './hooks/useCoderEntityConfig'; +import { CoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; import { type Workspace, workspaceBuildParametersSchema, @@ -144,7 +144,7 @@ async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { type WorkspacesByRepoFetchInputs = Readonly< WorkspacesFetchInputs & { - repoConfig: CoderEntityConfig; + workspacesConfig: CoderWorkspacesConfig; } >; @@ -162,7 +162,7 @@ export async function getWorkspacesByRepo( ), ); - const { repoConfig } = inputs; + const { workspacesConfig } = inputs; const matchedWorkspaces: Workspace[] = []; for (const [index, res] of paramResults.entries()) { @@ -172,8 +172,8 @@ export async function getWorkspacesByRepo( for (const param of res.value) { const include = - repoConfig.repoUrlParamKeys.includes(param.name) && - param.value === repoConfig.repoUrl; + workspacesConfig.repoUrlParamKeys.includes(param.name) && + param.value === workspacesConfig.repoUrl; if (include) { // Doing type assertion just in case noUncheckedIndexedAccess compiler diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx index 19456ea6..5d383be6 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx @@ -4,25 +4,22 @@ import React, { useContext, } from 'react'; -import type { YamlConfig } from '../../hooks/useCoderEntityConfig'; - -export type CoderWorkspaceConfig = Readonly< - Exclude & { - // Only specified explicitly to make templateName required - templateName: string; - - // Defined like this to ensure array always has at least one element - repoUrlParamKeys: readonly [string, ...string[]]; - } ->; - -export type CoderDeploymentConfig = Readonly<{ - accessUrl: string; -}>; +import type { YamlConfig } from '../../hooks/useCoderWorkspacesConfig'; export type CoderAppConfig = Readonly<{ - workspaces: CoderWorkspaceConfig; - deployment: CoderDeploymentConfig; + deployment: Readonly<{ + accessUrl: string; + }>; + + workspaces: Readonly< + Exclude & { + // Only specified explicitly to make templateName required + templateName: string; + + // Defined like this to ensure array always has at least one element + repoUrlParamKeys: readonly [string, ...string[]]; + } + >; }>; const AppConfigContext = createContext(null); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx index 06a44f39..10c8fb86 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx @@ -46,14 +46,14 @@ export const CreateWorkspaceLink = ({ ...delegatedProps }: CreateButtonLinkProps) => { const styles = useStyles(); - const { workspaceCreationLink } = useWorkspacesCardContext(); + const { workspacesConfig } = useWorkspacesCardContext(); return ( {children ?? } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx index 745dbd75..38180555 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx @@ -70,11 +70,11 @@ export const HeaderRow = ({ fullBleedLayout = true, ...delegatedProps }: HeaderProps) => { - const { headerId, entityConfig } = useWorkspacesCardContext(); + const { headerId, workspacesConfig } = useWorkspacesCardContext(); const styles = useStyles({ fullBleedLayout }); const HeadingComponent = headerLevel ?? 'h2'; - const repoUrl = entityConfig?.repoUrl; + const { repoUrl } = workspacesConfig; return (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.tsx index d51c3290..ac4f44fe 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.tsx @@ -61,7 +61,7 @@ export const Placeholder = ({ displayCta = false, }: PlaceholderProps) => { const styles = usePlaceholderStyles(); - const { workspaceCreationLink } = useWorkspacesCardContext(); + const { workspacesConfig } = useWorkspacesCardContext(); return (
@@ -71,7 +71,7 @@ export const Placeholder = ({ {displayCta && (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 139fb8b3..39056a55 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -7,25 +7,22 @@ import React, { import { useId } from '../../hooks/hookPolyfills'; import { UseQueryResult } from '@tanstack/react-query'; import { - useCoderEntityConfig, - type CoderEntityConfig, -} from '../../hooks/useCoderEntityConfig'; + useCoderWorkspacesConfig, + type CoderWorkspacesConfig, +} from '../../hooks/useCoderWorkspacesConfig'; import type { Workspace } from '../../typesConstants'; -import { useCoderWorkspaces } from '../../hooks/useCoderWorkspaces'; +import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; import { Card } from '../Card'; import { CoderAuthWrapper } from '../CoderAuthWrapper'; - -import { type CoderWorkspaceConfig, useCoderAppConfig } from '../CoderProvider'; import { EntityDataReminder } from './EntityDataReminder'; type WorkspacesCardContext = Readonly<{ queryFilter: string; onFilterChange: (newFilter: string) => void; workspacesQuery: UseQueryResult; + workspacesConfig: CoderWorkspacesConfig; headerId: string; - entityConfig: CoderEntityConfig | undefined; - workspaceCreationLink: string; }>; const CardContext = createContext(null); @@ -49,25 +46,20 @@ export const Root = ({ ...delegatedProps }: WorkspacesCardProps) => { const hookId = useId(); - const appConfig = useCoderAppConfig(); const [innerFilter, setInnerFilter] = useState(defaultQueryFilter); const activeFilter = outerFilter ?? innerFilter; - const dynamicConfig = useDynamicEntityConfig(readEntityData); - const workspacesQuery = useCoderWorkspaces(activeFilter, { - repoConfig: dynamicConfig, + const workspacesConfig = useCoderWorkspacesConfig({ readEntityData }); + const workspacesQuery = useCoderWorkspacesQuery({ + workspacesConfig, + coderQuery: activeFilter, }); const headerId = `${hookId}-header`; - const activeConfig = { - ...appConfig.workspaces, - ...(dynamicConfig ?? {}), - }; - const showEntityDataReminder = - workspacesQuery.data !== undefined && - dynamicConfig !== undefined && - !dynamicConfig.repoUrl; + readEntityData && + !workspacesConfig.repoUrl && + workspacesQuery.data !== undefined; return ( @@ -75,16 +67,12 @@ export const Root = ({ value={{ headerId, workspacesQuery, + workspacesConfig, queryFilter: activeFilter, - entityConfig: dynamicConfig, onFilterChange: newFilter => { setInnerFilter(newFilter); onOuterFilterChange?.(newFilter); }, - workspaceCreationLink: serializeWorkspaceUrl( - activeConfig, - appConfig.deployment.accessUrl, - ), }} > {/* @@ -109,7 +97,6 @@ export const Root = ({ export function useWorkspacesCardContext(): WorkspacesCardContext { const contextValue = useContext(CardContext); - if (contextValue === null) { throw new Error( `Not calling ${useWorkspacesCardContext.name} from inside a ${Root.name}`, @@ -118,53 +105,3 @@ export function useWorkspacesCardContext(): WorkspacesCardContext { return contextValue; } - -function useDynamicEntityConfig( - isEntityLayout: boolean, -): CoderEntityConfig | undefined { - const [initialEntityLayout] = useState(isEntityLayout); - - // Manually throwing error to cut off any potential hooks bugs early - if (isEntityLayout !== initialEntityLayout) { - throw new Error( - 'The value of entityLayout is not allowed to change across re-renders', - ); - } - - let entityConfig: CoderEntityConfig | undefined = undefined; - if (isEntityLayout) { - /* eslint-disable-next-line react-hooks/rules-of-hooks -- - The hook call is conditional, but the condition above ensures it will be - locked in for the lifecycle of the component. The hook call order will - never change, which is what the rule is trying to protect you from */ - entityConfig = useCoderEntityConfig(); - } - - return entityConfig; -} - -function serializeWorkspaceUrl( - config: CoderWorkspaceConfig, - coderAccessUrl: string, -): string { - const formattedParams = new URLSearchParams({ - mode: (config.mode ?? 'manual') satisfies CoderWorkspaceConfig['mode'], - }); - - const unformatted = config.params; - if (unformatted !== undefined && unformatted.hasOwnProperty) { - for (const key in unformatted) { - if (!unformatted.hasOwnProperty(key)) { - continue; - } - - const value = unformatted[key]; - if (value !== undefined) { - formattedParams.append(`param.${key}`, value); - } - } - } - - const safeTemplate = encodeURIComponent(config.templateName); - return `${coderAccessUrl}/templates/${safeTemplate}/workspace?${formattedParams.toString()}`; -} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index 8d7e3f8d..c6a92239 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -76,10 +76,10 @@ export const WorkspacesList = ({ fullBleedLayout = true, ...delegatedProps }: WorkspacesListProps) => { - const { workspacesQuery, entityConfig } = useWorkspacesCardContext(); + const { workspacesQuery, workspacesConfig } = useWorkspacesCardContext(); const styles = useWorkspacesListStyles({ fullBleedLayout }); - const repoUrl = entityConfig?.repoUrl ?? ''; + const repoUrl = workspacesConfig.repoUrl ?? ''; const ListItemContainer = ordered ? 'ol' : 'ul'; return ( diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.ts deleted file mode 100644 index 59f94394..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { useMemo } from 'react'; - -import { - type Output, - literal, - object, - optional, - record, - string, - undefined_, - union, - parse, -} from 'valibot'; - -import { useApi } from '@backstage/core-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { - getEntitySourceLocation, - useEntity, -} from '@backstage/plugin-catalog-react'; -import { - useCoderAppConfig, - type CoderWorkspaceConfig, -} from '../components/CoderProvider'; - -// Very loose parsing requirements to make interfacing with various kinds of -// YAML files as easy as possible -const yamlConfigSchema = union([ - undefined_(), - object({ - templateName: optional(string()), - mode: optional( - union( - [literal('manual'), literal('auto')], - "If defined, createMode must be 'manual' or 'auto'", - ), - ), - - params: optional( - record( - string(), - - // Defining record value with undefined case as a safety net if user - // hasn't or can't turn on the noUncheckedIndexedAccess compiler option - union([string(), undefined_()]), - 'If defined, params must be JSON-serializable as Record', - ), - ), - }), -]); - -export type YamlConfig = Output; - -export type CoderEntityConfig = Readonly< - { - [Key in keyof CoderWorkspaceConfig]-?: Readonly; - } & { - // repoUrl can't be definitely defined because (1) the value comes from an - // API that also doesn't give you a guarantee, and (2) it shouldn't be - // defined if repo info somehow isn't available - repoUrl: string | undefined; - } ->; - -export function compileCoderConfig( - workspaceSettings: CoderWorkspaceConfig, - rawYamlConfig: unknown, - repoUrl: string | undefined, -): CoderEntityConfig { - const compiledParams: Record = {}; - const yamlConfig = parse(yamlConfigSchema, rawYamlConfig); - - const paramsPrecedence = [workspaceSettings.params, yamlConfig?.params ?? {}]; - - // Can't replace this with destructuring, because that is all-or-nothing; - // there's no easy way to granularly check each property without a loop - for (const params of paramsPrecedence) { - for (const key in params) { - if (params.hasOwnProperty(key) && typeof params[key] === 'string') { - compiledParams[key] = params[key]; - } - } - } - - let cleanedUrl = repoUrl; - if (repoUrl !== undefined) { - // repoUrl usually ends with /tree/main/, which breaks Coder's logic for - // pulling down repos - cleanedUrl = repoUrl.replace(/\/tree\/main\/?$/, ''); - for (const key of workspaceSettings.repoUrlParamKeys) { - compiledParams[key] = cleanedUrl; - } - } - - return { - repoUrl: cleanedUrl, - repoUrlParamKeys: workspaceSettings.repoUrlParamKeys, - params: compiledParams, - templateName: yamlConfig?.templateName ?? workspaceSettings.templateName, - mode: yamlConfig?.mode ?? workspaceSettings.mode ?? 'manual', - }; -} - -export function useCoderEntityConfig(): CoderEntityConfig { - const { entity } = useEntity(); - const appConfig = useCoderAppConfig(); - const sourceControlApi = useApi(scmIntegrationsApiRef); - - const rawYamlConfig = entity.spec?.coder; - const repoData = getEntitySourceLocation(entity, sourceControlApi); - - return useMemo(() => { - return compileCoderConfig( - appConfig.workspaces, - rawYamlConfig, - repoData?.locationTargetUrl, - ); - // Backstage seems to have stabilized the value of rawYamlConfig, so even - // when it's a object, useMemo shouldn't re-run unnecessarily - }, [appConfig.workspaces, rawYamlConfig, repoData?.locationTargetUrl]); -} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts similarity index 62% rename from plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts rename to plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts index ffc52e57..8e189225 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts @@ -1,21 +1,19 @@ import { ValiError } from 'valibot'; - import { renderHookAsCoderEntity } from '../testHelpers/setup'; -import { type CoderWorkspaceConfig } from '../components/CoderProvider'; - import { mockYamlConfig, mockAppConfig, - mockWorkspaceConfig, cleanedRepoUrl, rawRepoUrl, + mockCoderWorkspacesConfig, } from '../testHelpers/mockBackstageData'; import { - CoderEntityConfig, + CoderWorkspacesConfig, compileCoderConfig, - useCoderEntityConfig, + useCoderWorkspacesConfig, type YamlConfig, -} from './useCoderEntityConfig'; +} from './useCoderWorkspacesConfig'; +import { CoderAppConfig } from '../plugin'; describe(`${compileCoderConfig.name}`, () => { it('Throws a Valibot ValiError when YAML config is invalid', () => { @@ -45,20 +43,20 @@ describe(`${compileCoderConfig.name}`, () => { for (const input of [...wrongStructure, ...wrongTypes]) { expect(() => { - compileCoderConfig(mockWorkspaceConfig, input, cleanedRepoUrl); + compileCoderConfig(mockAppConfig, input, cleanedRepoUrl); }).toThrow(ValiError); } }); it('Defers to YAML keys if YAML and baseline params have key conflicts', () => { const result = compileCoderConfig( - mockWorkspaceConfig, + mockAppConfig, mockYamlConfig, 'https://www.github.com/coder/coder', ); expect(result).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ templateName: mockYamlConfig.templateName, mode: mockYamlConfig.mode, params: expect.objectContaining({ @@ -72,20 +70,19 @@ describe(`${compileCoderConfig.name}`, () => { const url = 'https://www.github.com/google2/the-sequel-to-google'; const urlKeys = ['one', 'nothing', 'wrong', 'with', 'me'] as const; - const baselineParams = Object.fromEntries(urlKeys.map(key => [key, ''])); - const baseline: CoderWorkspaceConfig = { - ...mockWorkspaceConfig, - repoUrlParamKeys: urlKeys, - params: baselineParams, + const baselineAppConfig: CoderAppConfig = { + ...mockAppConfig, + workspaces: { + ...mockAppConfig.workspaces, + repoUrlParamKeys: urlKeys, + params: Object.fromEntries(urlKeys.map(key => [key, ''])), + }, }; const yamlParams = Object.fromEntries(urlKeys.map(key => [key, 'blah'])); - const yaml: YamlConfig = { - ...mockYamlConfig, - params: yamlParams, - }; + const yaml: YamlConfig = { ...mockYamlConfig, params: yamlParams }; - const result = compileCoderConfig(baseline, yaml, url); + const result = compileCoderConfig(baselineAppConfig, yaml, url); expect(result.repoUrlParamKeys).toEqual(urlKeys); const finalParams = Object.fromEntries(urlKeys.map(key => [key, url])); @@ -94,36 +91,38 @@ describe(`${compileCoderConfig.name}`, () => { it('Removes additional URL paths if they are present at the end of the raw URL', () => { const result = compileCoderConfig( - mockWorkspaceConfig, + mockAppConfig, mockYamlConfig, rawRepoUrl, ); expect(result).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ repoUrl: cleanedRepoUrl, }), ); }); }); -describe(`${useCoderEntityConfig.name}`, () => { +describe(`${useCoderWorkspacesConfig.name}`, () => { it('Reads relevant data from CoderProvider, entity, and source control API', async () => { - const { result } = await renderHookAsCoderEntity(useCoderEntityConfig); - - expect(result.current).toEqual( - expect.objectContaining>({ - repoUrl: cleanedRepoUrl, - templateName: mockYamlConfig.templateName, - mode: 'auto', - repoUrlParamKeys: mockAppConfig.workspaces.repoUrlParamKeys, - params: { - ...mockAppConfig.workspaces.params, - region: mockYamlConfig.params?.region ?? '', - custom_repo: cleanedRepoUrl, - repo_url: cleanedRepoUrl, - }, - }), + const { result } = await renderHookAsCoderEntity(() => + useCoderWorkspacesConfig({ readEntityData: true }), ); + + expect(result.current).toEqual({ + mode: mockYamlConfig.mode, + repoUrl: cleanedRepoUrl, + creationUrl: mockCoderWorkspacesConfig.creationUrl, + templateName: mockYamlConfig.templateName, + repoUrlParamKeys: mockAppConfig.workspaces.repoUrlParamKeys, + + params: { + ...mockAppConfig.workspaces.params, + region: mockYamlConfig.params?.region, + custom_repo: cleanedRepoUrl, + repo_url: cleanedRepoUrl, + }, + }); }); }); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts new file mode 100644 index 00000000..999a60b7 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts @@ -0,0 +1,185 @@ +import { useMemo, useState } from 'react'; + +import { + type Output, + literal, + object, + optional, + record, + string, + undefined_, + union, + parse, +} from 'valibot'; + +import { useApi } from '@backstage/core-plugin-api'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { + getEntitySourceLocation, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { + type CoderAppConfig, + useCoderAppConfig, +} from '../components/CoderProvider'; + +// Very loose parsing requirements to make interfacing with various kinds of +// YAML files as easy as possible +const yamlConfigSchema = union([ + undefined_(), + object({ + templateName: optional(string()), + mode: optional( + union( + [literal('manual'), literal('auto')], + "If defined, createMode must be 'manual' or 'auto'", + ), + ), + + params: optional( + record( + string(), + + // Defining record value with undefined case as a safety net if user + // hasn't or can't turn on the noUncheckedIndexedAccess compiler option + union([string(), undefined_()]), + 'If defined, params must be JSON-serializable as Record', + ), + ), + }), +]); + +export type YamlConfig = Output; + +/** + * Provides a cleaned and pre-processed version of all repo data that can be + * sourced from CoderAppConfig and any entity data. + */ +export type CoderWorkspacesConfig = + // Was originally defined in terms of fancy mapped types; ended up being a bad + // idea, because it increased coupling in a bad way + Readonly<{ + creationUrl: string; + templateName: string; + repoUrlParamKeys: readonly string[]; + mode: 'manual' | 'auto'; + params: Record; + + // Always undefined if repo data is not available for any reason + repoUrl: string | undefined; + }>; + +export function compileCoderConfig( + appConfig: CoderAppConfig, + rawYamlConfig: unknown, + repoUrl: string | undefined, +): CoderWorkspacesConfig { + const { workspaces, deployment } = appConfig; + const yamlConfig = parse(yamlConfigSchema, rawYamlConfig); + const mode = yamlConfig?.mode ?? workspaces.mode ?? 'manual'; + + const urlParams = new URLSearchParams({ mode }); + const compiledParams: Record = {}; + + // Can't replace this with destructuring, because that is all-or-nothing; + // there's no easy way to granularly check each property without a loop + const paramsPrecedence = [workspaces.params, yamlConfig?.params ?? {}]; + for (const params of paramsPrecedence) { + for (const key in params) { + // This guard clause should never trigger - in place to satisfy the + // Backstage ESLint rules + if (!params.hasOwnProperty(key)) { + continue; + } + + const value = params[key]; + if (typeof value === 'string') { + compiledParams[key] = value; + urlParams.set(`param.${key}`, value); + } + } + } + + // Repo URL usually ends with /tree/main/, which breaks the Coder deployment's + // logic for pulling down repos + let cleanedRepoUrl = repoUrl; + if (repoUrl !== undefined) { + cleanedRepoUrl = repoUrl.replace(/\/tree\/[\w._-]+\/?$/, ''); + + for (const key of workspaces.repoUrlParamKeys) { + compiledParams[key] = cleanedRepoUrl; + urlParams.set(`param.${key}`, cleanedRepoUrl); + } + } + + const safeTemplate = encodeURIComponent( + yamlConfig?.templateName ?? workspaces.templateName, + ); + + const creationUrl = `${ + deployment.accessUrl + }/templates/${safeTemplate}/workspace?${urlParams.toString()}`; + + return { + creationUrl, + repoUrl: cleanedRepoUrl, + repoUrlParamKeys: workspaces.repoUrlParamKeys, + params: compiledParams, + templateName: yamlConfig?.templateName ?? workspaces.templateName, + mode: yamlConfig?.mode ?? workspaces.mode ?? 'manual', + }; +} + +type UseCoderWorkspacesConfigOptions = Readonly<{ + readEntityData?: boolean; +}>; + +export function useCoderWorkspacesConfig({ + readEntityData = false, +}: UseCoderWorkspacesConfigOptions): CoderWorkspacesConfig { + const appConfig = useCoderAppConfig(); + const { rawYaml, repoUrl } = useDynamicEntity(readEntityData); + + return useMemo( + () => compileCoderConfig(appConfig, rawYaml, repoUrl), + // Backstage seems to have stabilized the value of rawYamlConfig, so even + // when it's an object, useMemo shouldn't re-run unnecessarily + [appConfig, rawYaml, repoUrl], + ); +} + +type UseDynamicEntityResult = Readonly<{ + rawYaml: unknown; + repoUrl: string | undefined; +}>; + +function useDynamicEntity(readEntityData: boolean): UseDynamicEntityResult { + // Manually checking value change across renders so that if the value changes, + // we can throw a better error message + const [initialReadSetting] = useState(readEntityData); + if (readEntityData !== initialReadSetting) { + throw new Error( + 'The value of "readEntityData" is not allowed to change across re-renders', + ); + } + + let rawYaml: unknown = undefined; + let repoUrl: string | undefined = undefined; + + /* eslint-disable react-hooks/rules-of-hooks -- + Doing conditional hook calls here, but the throw assertion above ensures + the hook values will be locked in for the lifecycle of the component. The + hook call order will never change, which is what the rule is trying to + protect you from */ + if (readEntityData) { + const { entity } = useEntity(); + const sourceControlApi = useApi(scmIntegrationsApiRef); + const repoData = getEntitySourceLocation(entity, sourceControlApi); + + rawYaml = entity.spec?.coder; + repoUrl = repoData?.locationTargetUrl; + } + /* eslint-enable react-hooks/rules-of-hooks */ + + return { rawYaml, repoUrl } as const; +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts similarity index 78% rename from plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts rename to plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts index eb4674e1..0e1d9571 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts @@ -1,8 +1,8 @@ import { waitFor } from '@testing-library/react'; -import { useCoderWorkspaces } from './useCoderWorkspaces'; +import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; import { renderHookAsCoderEntity } from '../testHelpers/setup'; -import { mockCoderEntityConfig } from '../testHelpers/mockBackstageData'; +import { mockCoderWorkspacesConfig } from '../testHelpers/mockBackstageData'; beforeAll(() => { jest.useFakeTimers(); @@ -12,10 +12,10 @@ afterAll(() => { jest.clearAllTimers(); }); -describe(`${useCoderWorkspaces.name}`, () => { +describe(`${useCoderWorkspacesQuery.name}`, () => { it('Will make a request when provided correct inputs', async () => { const { result } = await renderHookAsCoderEntity(() => { - return useCoderWorkspaces('owner:me'); + return useCoderWorkspacesQuery({ coderQuery: 'owner:me' }); }); await waitFor(() => expect(result.current.status).toBe('success')); @@ -23,7 +23,7 @@ describe(`${useCoderWorkspaces.name}`, () => { it('Will not be enabled if auth token is missing', async () => { const { result } = await renderHookAsCoderEntity( - () => useCoderWorkspaces('owner:me'), + () => useCoderWorkspacesQuery({ coderQuery: 'owner:me' }), { authStatus: 'invalid' }, ); @@ -48,8 +48,9 @@ describe(`${useCoderWorkspaces.name}`, () => { it('Will only return workspaces for a given repo when a repoConfig is provided', async () => { const { result } = await renderHookAsCoderEntity(() => { - return useCoderWorkspaces('owner:me', { - repoConfig: mockCoderEntityConfig, + return useCoderWorkspacesQuery({ + coderQuery: 'owner:me', + workspacesConfig: mockCoderWorkspacesConfig, }); }); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts similarity index 51% rename from plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.ts rename to plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index c78c8524..22556fda 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -3,25 +3,23 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../api'; import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; import { useBackstageEndpoints } from './useBackstageEndpoints'; -import { CoderEntityConfig } from './useCoderEntityConfig'; +import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -type UseCoderWorkspacesOptions = Readonly< - Partial<{ - repoConfig: CoderEntityConfig; - }> ->; +type QueryInput = Readonly<{ + coderQuery: string; + workspacesConfig?: CoderWorkspacesConfig; +}>; -export function useCoderWorkspaces( - coderQuery: string, - options?: UseCoderWorkspacesOptions, -) { +export function useCoderWorkspacesQuery({ + coderQuery, + workspacesConfig, +}: QueryInput) { const auth = useCoderAuth(); const { baseUrl } = useBackstageEndpoints(); - const { repoConfig } = options ?? {}; - const hasRepoData = repoConfig && repoConfig.repoUrl; + const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ coderQuery, auth, baseUrl, repoConfig }) + ? workspacesByRepo({ coderQuery, auth, baseUrl, workspacesConfig }) : workspaces({ coderQuery, auth, baseUrl }); return useQuery(queryOptions); diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 790327aa..85ae7178 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -152,8 +152,8 @@ export const CoderWorkspacesCardWorkspacesListItem = coderPlugin.provide( /** * All custom hooks exposed by the plugin. */ -export { useCoderEntityConfig } from './hooks/useCoderEntityConfig'; -export { useCoderWorkspaces } from './hooks/useCoderWorkspaces'; +export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +export { useCoderWorkspacesQuery } from './hooks/useCoderWorkspacesQuery'; export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root'; /** diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 5ef2c38b..1131b6f2 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -6,15 +6,14 @@ import type { ScmIntegrationRegistry } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; import { - CoderWorkspaceConfig, type CoderAppConfig, - CoderAuth, - CoderAuthStatus, + type CoderAuth, + type CoderAuthStatus, } from '../components/CoderProvider'; import { - CoderEntityConfig, + CoderWorkspacesConfig, type YamlConfig, -} from '../hooks/useCoderEntityConfig'; +} from '../hooks/useCoderWorkspacesConfig'; import { ScmIntegrationsApi } from '@backstage/integration-react'; import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; @@ -27,11 +26,11 @@ import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; const ANNOTATION_SOURCE_LOCATION_KEY = 'backstage.io/source-location'; /** - * The URL that will be exposed via useCoderEntityConfig. This value must have - * all additional parts at the end stripped off in order to make sure that the - * Coder app is correctly able to download a repo for a workspace. + * The URL that will be exposed via useCoderWorkspacesConfig. This value must + * have all additional parts at the end stripped off in order to make sure that + * the Coder app is correctly able to download a repo for a workspace. */ -export const cleanedRepoUrl = 'https://www.zombo.com'; +export const cleanedRepoUrl = 'https://www.github.com/zombo/com'; /** * The shape of URL that Backstage will parse from the entity data by default @@ -58,7 +57,7 @@ export const mockYamlConfig = { mode: 'auto', params: { region: 'brazil', - } as NonNullable['params'], + } satisfies NonNullable['params'], } as const satisfies YamlConfig; export type BackstageEntity = ReturnType['entity']; @@ -77,37 +76,50 @@ export const mockEntity: BackstageEntity = { }, }; -export const mockWorkspaceConfig: CoderWorkspaceConfig = { - templateName: 'devcontainers', - mode: 'manual', - repoUrlParamKeys: ['custom_repo', 'repo_url'], - params: { - repo: 'custom', - region: 'eu-helsinki', - }, -}; - -export const mockCoderEntityConfig: CoderEntityConfig = { - mode: 'manual', - templateName: 'mock-entity-config', - repoUrlParamKeys: ['custom_repo', 'repo_url'], - repoUrl: cleanedRepoUrl, - params: { - repo: 'custom', - region: 'eu-helsinki', - custom_repo: cleanedRepoUrl, - repo_url: cleanedRepoUrl, - }, -}; - export const mockAppConfig = { deployment: { accessUrl: 'https://dev.coder.com', }, - workspaces: mockWorkspaceConfig, + workspaces: { + templateName: 'devcontainers', + mode: 'manual', + repoUrlParamKeys: ['custom_repo', 'repo_url'], + params: { + repo: 'custom', + region: 'eu-helsinki', + }, + }, } as const satisfies CoderAppConfig; +export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { + const urlParams = new URLSearchParams({ + mode: mockYamlConfig.mode, + 'param.repo': mockAppConfig.workspaces.params.repo, + 'param.region': mockYamlConfig.params.region, + 'param.custom_repo': cleanedRepoUrl, + 'param.repo_url': cleanedRepoUrl, + }); + + return { + mode: 'auto', + templateName: mockYamlConfig.templateName, + repoUrlParamKeys: ['custom_repo', 'repo_url'], + repoUrl: cleanedRepoUrl, + + creationUrl: `${mockAppConfig.deployment.accessUrl}/templates/${ + mockYamlConfig.templateName + }/workspace?${urlParams.toString()}`, + + params: { + repo: 'custom', + region: 'eu-helsinki', + custom_repo: cleanedRepoUrl, + repo_url: cleanedRepoUrl, + }, + }; +})(); + const authedState = { token: mockCoderAuthToken, error: undefined, diff --git a/plugins/backstage-plugin-devcontainers-react/package.json b/plugins/backstage-plugin-devcontainers-react/package.json index 16407682..5edb702e 100644 --- a/plugins/backstage-plugin-devcontainers-react/package.json +++ b/plugins/backstage-plugin-devcontainers-react/package.json @@ -31,8 +31,7 @@ "@backstage/theme": "^0.5.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.61", - "react-use": "^17.2.4" + "@material-ui/lab": "4.0.0-alpha.61" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" From 343c86629d80941a38f4ab259ec054612b8cbe8a Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 28 Mar 2024 17:55:48 -0400 Subject: [PATCH 10/33] chore: add missing unit tests for Coder plugin (#83) * fix: fix hover behavior for last list item * fix: shrink default max height for container * fix: ensure divider bar appears when there is overflow * refactor: add workspaceCreationLink prop to context provider * refactor: split Placeholder into separate component * chore: finish cta button * fix: make sure button only appears when loading is finished * docs: remove bad comment * chore: add explicit return type to useCoderAppConfig for clarity * refactor: consolidate and decouple type definitions * refactor: move dynamic entity config logic * refactor: update references for workspaces config * refactor: centralize creationUrl logic * refactor: rename useCoderEntityConfig to useCoderWorkspacesConfig * refactor: rename old useCoderWorkspaces to useCoderWorkspacesQuery * fix: update typo in test case * fix: update test logic to account for creationUrl * fix: update query logic to account for always-defined workspacesConfig * docs: fix typo in comment * refactor: clean up how mock data is defined * fix: make logic for showing reminder more airtight * refactor: split DataReminder into separate file * refactor: simplify API for useCoderWorkspacesQuery * fix: make sure data reminder only shows when appropriate * wip: commit progress on auth test * chore: simplify setup for CoderProviderWithMockAuth * wip: reorganize test structure * chore: update test helper to accept mock callbacks * wip: commit more test progress * chore: finish tests for CoderAuthWrapper * refactor: make error message more user-friendly * fix: delete stale DataReminder file * fix: delete untested test helpers * chore: finish tests for CreateWorkspaceLink * refactor: extract test setup logic into helper * chore: finish tests for EntityDataReminder * fix: update indenting level for comment * wip: add stubs for ExtraActionsButton * refactor: update APIs for test helpers * chore: add test case for submitting new token * chore: let user pass in custom query client for test helper * wip: lay out test stubs for ExtraActionsButton * refactor: simplify API for renderInCoderEnvironment * wip: commit progress on ExtraActionsButton * wip: add test case for keyboard input * fix: make better assertions about auto-focus * chore: finish tests for ExtraActionsButton * chore: update test helper to accept custom entity * chore: add mock repo name value * wip: commit current progress on HeaderRow tests * chore: finish tests for HeaderRow * docs: update comment for clarity * fix: update repo URL parsing logic * chore: add test for Placeholder * refactor: update type definitions for ExtraActionsButton test * wip: add stub logic for SearchBox tests * docs: add note about how Root probably shouldn't be tested * wip: add stubs for CoderWorkspacesCard tests * wip: reorganize test cases * chore: finish initial draft of tests for SearchBox * chore: update test logic to account for debounces * fix: update test to account for throttles better * wip: commit progress for Root tests * chore: finish tests for Root * wip: commit progress on WorkspacesListIcon test * refactor: update WorkspacesListIcon to be easier to test * chore: finish tests for WorkspacesListIcon * chore: add tests for WorkspacesListItem * docs: add note about scope of tests for Root * chore: finish tests for WorkspacesListItem * chore: finish all unit tests * fix: delete empty test file (to be added in future PR) * docs: update type definitions * docs: update hook/type docs to reflect new APIs * docs: fix typo * chore: try removing react-use dependency to make CI happy --- .../CoderAuthWrapper/CoderAuthInputForm.tsx | 2 +- .../CoderAuthWrapper.test.tsx | 198 ++++++++++++++++++ .../CoderAuthWrapper/CoderAuthWrapper.tsx | 2 +- .../CreateWorkspaceLink.test.tsx | 40 ++++ .../EntityDataReminder.test.tsx | 34 +++ .../ExtraActionsButton.test.tsx | 181 ++++++++++++++++ .../ExtraActionsButton.tsx | 10 +- .../CoderWorkspacesCard/HeaderRow.test.tsx | 81 +++++++ .../CoderWorkspacesCard/HeaderRow.tsx | 10 +- .../CoderWorkspacesCard/Placeholder.test.tsx | 28 +++ .../CoderWorkspacesCard/Root.test.tsx | 78 +++++++ .../components/CoderWorkspacesCard/Root.tsx | 17 +- .../CoderWorkspacesCard/SearchBox.test.tsx | 137 ++++++++++++ .../CoderWorkspacesCard/SearchBox.tsx | 3 +- .../WorkspacesList.test.tsx | 66 ++++++ .../CoderWorkspacesCard/WorkspacesList.tsx | 6 +- .../WorkspacesListIcon.test.tsx | 31 +++ .../WorkspacesListIcon.tsx | 7 +- .../WorkspacesListItem.test.tsx | 48 +++++ .../src/testHelpers/mockBackstageData.ts | 13 +- .../src/testHelpers/setup.tsx | 178 ++++++++-------- 21 files changed, 1053 insertions(+), 117 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx index f8dff1ad..40cc6784 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -128,8 +128,8 @@ export const CoderAuthInputForm = () => { // won't connect the label and input together, which breaks // accessibility for screen readers. Need to wire up extra IDs, sadly. label="Auth token" - id={authTokenInputId} InputLabelProps={{ htmlFor: authTokenInputId }} + InputProps={{ id: authTokenInputId }} required name="authToken" type="password" diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx new file mode 100644 index 00000000..de33394a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; +import type { CoderAuth, CoderAuthStatus } from '../CoderProvider'; +import { + mockAppConfig, + mockAuthStates, + mockCoderAuthToken, +} from '../../testHelpers/mockBackstageData'; +import { CoderAuthWrapper } from './CoderAuthWrapper'; +import { renderInTestApp } from '@backstage/test-utils'; + +type RenderInputs = Readonly<{ + childButtonText: string; + authStatus: CoderAuthStatus; +}>; + +async function renderAuthWrapper({ + authStatus, + childButtonText, +}: RenderInputs) { + const ejectToken = jest.fn(); + const registerNewToken = jest.fn(); + + const auth: CoderAuth = { + ...mockAuthStates[authStatus], + ejectToken, + registerNewToken, + }; + + /** + * @todo RTL complains about the current environment not being configured to + * support act. Luckily, it doesn't cause any of our main test cases to kick + * up false positives. + * + * This may not be an issue with our code, and might be a bug from Backstage's + * migration to React 18. Need to figure out where this issue is coming from, + * and open an issue upstream if necessary + */ + const renderOutput = await renderInTestApp( + + + + + , + ); + + return { ...renderOutput, ejectToken, registerNewToken }; +} + +describe(`${CoderAuthWrapper.name}`, () => { + describe('Displaying main content', () => { + it('Displays the main children when the user is authenticated', async () => { + const buttonText = 'I have secret Coder content!'; + renderAuthWrapper({ + authStatus: 'authenticated', + childButtonText: buttonText, + }); + + const button = await screen.findByRole('button', { name: buttonText }); + + // This assertion isn't necessary because findByRole will throw an error + // if the button can't be found within the expected period of time. Doing + // this purely to make the Backstage linter happy + expect(button).toBeInTheDocument(); + }); + }); + + describe('Loading UI', () => { + it('Is displayed while the auth is initializing', async () => { + const buttonText = "You shouldn't be able to see me!"; + renderAuthWrapper({ + authStatus: 'initializing', + childButtonText: buttonText, + }); + + await screen.findByText(/Loading/); + const button = screen.queryByRole('button', { name: buttonText }); + expect(button).not.toBeInTheDocument(); + }); + }); + + describe('Token distrusted form', () => { + it("Is displayed when the user's auth status cannot be verified", async () => { + const buttonText = 'Not sure if you should be able to see me'; + const distrustedTextMatcher = /Unable to verify token authenticity/; + const distrustedStatuses: readonly CoderAuthStatus[] = [ + 'distrusted', + 'noInternetConnection', + 'deploymentUnavailable', + ]; + + for (const status of distrustedStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus: status, + childButtonText: buttonText, + }); + + await screen.findByText(distrustedTextMatcher); + const button = screen.queryByRole('button', { name: buttonText }); + expect(button).not.toBeInTheDocument(); + + unmount(); + } + }); + + it('Lets the user eject the current token', async () => { + const { ejectToken } = await renderAuthWrapper({ + authStatus: 'distrusted', + childButtonText: "I don't matter", + }); + + const user = userEvent.setup(); + const ejectButton = await screen.findByRole('button', { + name: 'Eject token', + }); + + await user.click(ejectButton); + expect(ejectToken).toHaveBeenCalled(); + }); + + it('Will appear if auth status changes during re-renders', async () => { + const buttonText = "Now you see me, now you don't"; + const { rerender } = await renderAuthWrapper({ + authStatus: 'authenticated', + childButtonText: buttonText, + }); + + // Capture button after it first appears on the screen + const button = await screen.findByRole('button', { name: buttonText }); + + rerender( + + + + + , + ); + + // Assert that the button is now gone + expect(button).not.toBeInTheDocument(); + }); + }); + + describe('Token submission form', () => { + it("Is displayed when the token either doesn't exist or is definitely not valid", async () => { + const buttonText = "You're not allowed to gaze upon my visage"; + const tokenFormMatcher = /Please enter a new token/; + const statusesForInvalidUser: readonly CoderAuthStatus[] = [ + 'invalid', + 'tokenMissing', + ]; + + for (const status of statusesForInvalidUser) { + const { unmount } = await renderAuthWrapper({ + authStatus: status, + childButtonText: buttonText, + }); + + await screen.findByText(tokenFormMatcher); + const button = screen.queryByRole('button', { name: buttonText }); + expect(button).not.toBeInTheDocument(); + + unmount(); + } + + expect.hasAssertions(); + }); + + it('Lets the user submit a new token', async () => { + const { registerNewToken } = await renderAuthWrapper({ + authStatus: 'tokenMissing', + childButtonText: "I don't matter", + }); + + /** + * Two concerns that make the selection for inputField a little hokey: + * 1. The auth input is of type password, which does not have a role + * compatible with Testing Library; can't use getByRole to select it + * 2. MUI adds a star to its labels that are required, meaning that any + * attempts at trying to match the string "Auth token" will fail + */ + const inputField = screen.getByLabelText(/Auth token/); + const submitButton = screen.getByRole('button', { name: 'Authenticate' }); + + const user = userEvent.setup(); + await user.click(inputField); + await user.keyboard(mockCoderAuthToken); + await user.click(submitButton); + + expect(registerNewToken).toHaveBeenCalledWith(mockCoderAuthToken); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx index 0bfdff65..b0e6ee22 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx @@ -70,7 +70,7 @@ export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { case 'authenticated': case 'distrustedWithGracePeriod': { throw new Error( - 'This code should be unreachable because of the auth check near the start of the component', + 'Tried to process authenticated user after main content should already be shown', ); } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx new file mode 100644 index 00000000..b26c86f1 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mockAppConfig } from '../../testHelpers/mockBackstageData'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { Root } from './Root'; +import { CreateWorkspaceLink } from './CreateWorkspaceLink'; + +function render() { + return renderInCoderEnvironment({ + children: ( + + + + ), + }); +} + +describe(`${CreateWorkspaceLink.name}`, () => { + it('Displays a link based on the current entity', async () => { + await render(); + const link = screen.getByRole('link'); + + expect(link).not.toBeDisabled(); + expect(link.target).toEqual('_blank'); + expect(link.href).toMatch( + new RegExp(`^${mockAppConfig.deployment.accessUrl}/`), + ); + }); + + it('Will display a tooltip while hovered over', async () => { + await render(); + const link = screen.getByRole('link'); + const user = userEvent.setup(); + + await user.hover(link); + const tooltip = await screen.findByText('Add a new workspace'); + expect(tooltip).toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx new file mode 100644 index 00000000..61536c72 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { Root } from './Root'; +import { EntityDataReminder } from './EntityDataReminder'; + +function render() { + return renderInCoderEnvironment({ + children: ( + + + + ), + }); +} + +describe(`${EntityDataReminder.name}`, () => { + it('Will toggle between showing/hiding the disclosure info when the user clicks it', async () => { + await render(); + const user = userEvent.setup(); + const disclosureButton = screen.getByRole('button', { + name: /Why am I seeing all workspaces\?/, + }); + + await user.click(disclosureButton); + const disclosureInfo = await screen.findByText( + /This component displays all workspaces when the entity has no repo URL to filter by/, + ); + + await user.click(disclosureButton); + expect(disclosureInfo).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx new file mode 100644 index 00000000..732a859d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { + mockAuthStates, + mockCoderWorkspacesConfig, +} from '../../testHelpers/mockBackstageData'; +import type { CoderAuth } from '../CoderProvider'; +import { CardContext, WorkspacesCardContext } from './Root'; +import { ExtraActionsButton } from './ExtraActionsButton'; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +function getUser() { + return userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); +} + +type RenderInputs = Readonly<{ + buttonText: string; +}>; + +async function renderButton({ buttonText }: RenderInputs) { + const ejectToken = jest.fn(); + const auth: CoderAuth = { ...mockAuthStates.authenticated, ejectToken }; + + /** + * Pretty sure there has to be a more elegant and fault-tolerant way of + * testing the useQuery functionality, but this does the trick for now + * + * @todo Research how to test dependencies on useQuery + */ + const refetch = jest.fn(); + const mockWorkspacesQuery = { + refetch, + } as unknown as WorkspacesCardContext['workspacesQuery']; + const mockContext: WorkspacesCardContext = { + headerId: "Doesn't matter", + queryFilter: "Doesn't matter", + onFilterChange: jest.fn(), + workspacesConfig: mockCoderWorkspacesConfig, + workspacesQuery: mockWorkspacesQuery, + }; + + const renderOutput = await renderInCoderEnvironment({ + auth, + children: ( + + + + ), + }); + + return { + ...renderOutput, + button: screen.getByRole('button', { name: new RegExp(buttonText) }), + unlinkCoderAccount: ejectToken, + refreshWorkspaces: refetch, + }; +} + +describe(`${ExtraActionsButton.name}`, () => { + // Can include onClick prop test in this test case, too + it('Will open a menu of actions when the main button is clicked', async () => { + const { button } = await renderButton({ buttonText: 'Button' }); + const user = getUser(); + + await user.click(button); + expect(() => { + screen.getByRole('menuitem', { + name: /Unlink Coder account/i, + }); + + screen.getByRole('menuitem', { + name: /Refresh/i, + }); + }).not.toThrow(); + }); + + it('Displays a tooltip when the user hovers over it', async () => { + const tooltipText = 'Hover test'; + const user = getUser(); + const { button } = await renderButton({ + buttonText: 'Hover test', + }); + + await user.hover(button); + const tooltip = await screen.findByText(tooltipText); + expect(tooltip).toBeInTheDocument(); + }); + + it('Can unlink the current Coder session token', async () => { + const user = getUser(); + const { button, unlinkCoderAccount } = await renderButton({ + buttonText: 'Unlink test', + }); + + await user.click(button); + const unlinkMenuItem = await screen.findByRole('menuitem', { + name: /Unlink Coder account/i, + }); + + await user.click(unlinkMenuItem); + expect(unlinkCoderAccount).toHaveBeenCalled(); + }); + + it('Lets users trigger actions entirely through the keyboard', async () => { + const tooltipText = 'Keyboard test'; + const { button, unlinkCoderAccount } = await renderButton({ + buttonText: tooltipText, + }); + + const user = getUser(); + await user.keyboard('[Tab]'); + expect(button).toHaveFocus(); + + await user.keyboard('[Enter]'); + const menuItems = await screen.findAllByRole('menuitem'); + expect(menuItems[0]).toHaveFocus(); + + const unlinkItem = screen.getByRole('menuitem', { + name: /Unlink Coder account/i, + }); + + while (document.activeElement !== unlinkItem) { + await user.keyboard('[ArrowDown]'); + } + + await user.keyboard('[Enter]'); + expect(unlinkCoderAccount).toHaveBeenCalled(); + }); + + it('Can refresh the workspaces data', async () => { + const user = getUser(); + const { button, refreshWorkspaces } = await renderButton({ + buttonText: 'Refresh test', + }); + + await user.click(button); + const refreshItem = await screen.findByRole('menuitem', { + name: /Refresh/i, + }); + + await user.click(refreshItem); + expect(refreshWorkspaces).toHaveBeenCalled(); + }); + + it('Will throttle repeated clicks on the Refresh menu item', async () => { + const user = getUser(); + const refreshMatcher = /Refresh/i; + const { button, refreshWorkspaces } = await renderButton({ + buttonText: 'Throttle test', + }); + + // The menu is programmed to auto-close every time you choose an option; + // have to do a lot of clicks to verify that things are throttled + for (let i = 0; i < 10; i++) { + await user.click(button); + + // Can't store this in a variable outside the loop, because the item will + // keep mounting/unmounting every time the menu opens/closes. The memory + // reference will keep changing + const refreshItem = screen.getByRole('menuitem', { + name: refreshMatcher, + }); + + await user.click(refreshItem); + } + + await jest.advanceTimersByTimeAsync(10_000); + expect(refreshWorkspaces).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 38b63b95..57a41922 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -137,11 +137,11 @@ export const ExtraActionsButton = ({

{/* Warning: all direct children of Menu must be MenuItem components, or - else the auto-focus behavior will break. Even a custom component that - returns out nothing but a MenuItem will break it. (Guessing that MUI - uses something like cloneElement under the hood, and that they're - interacting with the raw JSX metadata objects before they're turned - into new UI.) */} + else the auto-focus behavior will break. Even a custom component that + returns out nothing but a MenuItem will break it. (Guessing that MUI + uses something like cloneElement under the hood, and that they're + interacting with the raw JSX metadata objects before they're turned + into new UI.) */} ; + +function renderHeaderRow(input?: RenderInputs) { + const { repoUrl, readEntityData = false } = input ?? {}; + + let entity: BackstageEntity = mockEntity; + if (repoUrl) { + entity = { + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ...(mockEntity.metadata?.annotations ?? {}), + [ANNOTATION_SOURCE_LOCATION_KEY]: `url:${repoUrl}`, + }, + }, + }; + } + + return renderInCoderEnvironment({ + entity, + children: ( + + + + ), + }); +} + +describe(`${HeaderRow.name}`, () => { + const subheaderTextMatcher = /Results filtered by/i; + + it('Has a header with an ID that matches the ID of the parent root container (needed for a11y landmark behavior)', async () => { + await renderHeaderRow(); + const searchContainer = screen.getByRole('search'); + const header = screen.getByRole('heading'); + + const labelledByBinding = searchContainer.getAttribute('aria-labelledby'); + expect(header.id).toBe(labelledByBinding); + }); + + it('Will hide text about filtering active repos if the Root is not configured to read entity data', async () => { + await renderHeaderRow({ readEntityData: false }); + const subheader = screen.queryByText(subheaderTextMatcher); + expect(subheader).not.toBeInTheDocument(); + }); + + it('Will dynamically show the name of the current repo (when it can be parsed)', async () => { + await renderHeaderRow({ readEntityData: true }); + const subheader = screen.getByText(subheaderTextMatcher); + + expect(subheader.textContent).toEqual( + `Results filtered by repo: ${mockRepoName}`, + ); + }); + + it("Will show fallback indicator for the repo name if it can't be parsed", async () => { + await renderHeaderRow({ + readEntityData: true, + repoUrl: 'https://www.blah.com/unknown/repo/format', + }); + + const subheader = screen.getByText(subheaderTextMatcher); + expect(subheader.textContent).toEqual('Results filtered by repo URL'); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx index 38180555..8c67d5e5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx @@ -100,9 +100,13 @@ export const HeaderRow = ({ ); }; -// Temporary stopgap until we can figure out how to grab the repo name via one -// of the Backstage APIs -const repoNameRe = /^(?:https?:\/\/)?github\.com\/.*?\/(.+?)(?:\/.*)?$/i; +/** + * Parses the repo name from GitHub/GitLab/Bitbucket, which should be the last + * segment of the URL after it's been cleaned by the CoderConfig + */ +const repoNameRe = + /^(?:https?:\/\/)?(?:www\.)?(?:github|gitlab|bitbucket)\.com\/.*?\/(.+)?$/i; + function extractRepoName(repoUrl: string): string { const [, repoName] = repoNameRe.exec(repoUrl) ?? []; return repoName ? `repo: ${repoName}` : 'repo URL'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.test.tsx new file mode 100644 index 00000000..df18f7d3 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Placeholder.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { Root } from './Root'; +import { Placeholder } from './Placeholder'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { mockAppConfig } from '../../testHelpers/mockBackstageData'; + +describe(`${Placeholder.name}`, () => { + it('Lets the user create a new workspace when call-to-action behavior is enabled', async () => { + await renderInCoderEnvironment({ + children: ( + + + + ), + }); + + const link = screen.getByRole('link', { + name: /Create workspace/i, + }); + + expect(link).not.toBeDisabled(); + expect(link.target).toBe('_blank'); + expect(link.href).toMatch( + new RegExp(`^${mockAppConfig.deployment.accessUrl}/`), + ); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.test.tsx new file mode 100644 index 00000000..ad6c13bb --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.test.tsx @@ -0,0 +1,78 @@ +/** + * @file This file covers functionality that is specific to the Root component + * when used by itself. + * + * For full integration tests (and test cases for the vast majority of + * meaningful functionality), see CoderWorkspacesCard.test.tsx + */ +import React, { type ReactNode } from 'react'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { Root } from './Root'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +type RenderInputs = Readonly<{ + children: ReactNode; +}>; + +async function renderRoot(inputs?: RenderInputs) { + const { children } = inputs ?? {}; + + // The onSubmit handler is designed not to be the direct recipient of submit + // events, but passively receive them as they're triggered in the form, and + // then bubble up towards the root of the DOM + const onSubmit = jest.fn(); + const renderOutput = await renderInCoderEnvironment({ + children: ( +
+ {children} +
+ ), + }); + + return { ...renderOutput, onSubmit }; +} + +describe(`${Root.name}`, () => { + it("Is exposed to the accessibility tree as a 'search' element", async () => { + await renderRoot(); + expect(() => screen.getByRole('search')).not.toThrow(); + }); + + it("Does not cause any button children of type 'submit' to trigger submit events when they are clicked", async () => { + const buttonText = "Don't trigger reloads please"; + const { onSubmit } = await renderRoot({ + // All buttons have type "submit" when the type isn't specified + children: , + }); + + const user = userEvent.setup(); + const button = screen.getByRole('button', { + name: buttonText, + }); + + await user.click(button); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('Does not make focused input children trigger submit events when the Enter key is pressed', async () => { + const inputLabel = "Don't reload on Enter, please"; + const { onSubmit } = await renderRoot({ + children: ( + + ), + }); + + const user = userEvent.setup(); + const input = screen.getByRole('textbox', { + name: inputLabel, + }); + + await user.click(input); + await user.keyboard('[Enter]'); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 39056a55..6829753a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -1,3 +1,10 @@ +/** + * @file Wires up all the core logic for passing values down to the + * sub-components in the same directory. + * + * Does not need any tests – test functionality covered by integration tests in + * CoderWorkspacesCard + */ import React, { type HTMLAttributes, createContext, @@ -17,15 +24,19 @@ import { Card } from '../Card'; import { CoderAuthWrapper } from '../CoderAuthWrapper'; import { EntityDataReminder } from './EntityDataReminder'; -type WorkspacesCardContext = Readonly<{ +export type WorkspacesQuery = UseQueryResult; + +export type WorkspacesCardContext = Readonly<{ queryFilter: string; onFilterChange: (newFilter: string) => void; - workspacesQuery: UseQueryResult; + workspacesQuery: WorkspacesQuery; workspacesConfig: CoderWorkspacesConfig; headerId: string; }>; -const CardContext = createContext(null); +// Only exported to simplify setting up dependency injection for tests. Should +// not be consumed directly in application code +export const CardContext = createContext(null); export type WorkspacesCardProps = Readonly< Omit, 'role' | 'aria-labelledby'> & { diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx new file mode 100644 index 00000000..ecb31bb7 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { CardContext, WorkspacesCardContext } from './Root'; +import { SearchBox } from './SearchBox'; +import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +function getUser() { + return userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); +} + +type RenderInputs = Readonly<{ + queryFilter?: string; +}>; + +async function renderSearchBox(input?: RenderInputs) { + const { queryFilter = 'owner:me' } = input ?? {}; + const onFilterChange = jest.fn(); + + const mockContext: WorkspacesCardContext = { + onFilterChange, + queryFilter, + headerId: "Doesn't matter", + workspacesConfig: mockCoderWorkspacesConfig, + workspacesQuery: + null as unknown as WorkspacesCardContext['workspacesQuery'], + }; + + const renderOutput = await renderInCoderEnvironment({ + children: ( + + + + ), + }); + + const inputField = screen.getByRole('searchbox', { + name: /Search your Coder workspaces/i, + }); + + return { ...renderOutput, inputField, onFilterChange }; +} + +describe(`${SearchBox.name}`, () => { + describe('General functionality', () => { + const sampleInputText = 'Here is some cool text'; + + it('Will update the input immediately in response to the user typing', async () => { + const { inputField } = await renderSearchBox(); + const user = getUser(); + + // Using triple-click to simulate highlighting all the text in the input + await user.tripleClick(inputField); + await user.keyboard(`[Backspace]${sampleInputText}`); + expect(inputField.value).toBe(sampleInputText); + }); + + it('Will debounce calls to the parent provider as the user types more characters', async () => { + const { inputField, onFilterChange } = await renderSearchBox(); + const user = getUser(); + + await user.click(inputField); + await user.keyboard(sampleInputText); + + expect(onFilterChange).not.toHaveBeenCalled(); + await waitFor(() => expect(onFilterChange).toHaveBeenCalledTimes(1)); + }); + }); + + /** + * Two ways to clear the input: + * 1. Clicking the clear button + * 2. Hitting backspace on the keyboard until the input field is empty + * + * Which both immediately cause the following behavior when triggered: + * 1. Clears out the visible input + * 2. Calls the Root query callback with an empty string + * 3. Cancels any pending debounced calls + */ + describe('Text-clearing functionality', () => { + it('Lets the user clear the text via the Clear button', async () => { + const user = getUser(); + const { inputField, onFilterChange } = await renderSearchBox({ + queryFilter: '', + }); + + const clearButton = screen.getByRole('button', { + name: /Clear out search/i, + }); + + const sampleInputText = 'clear me out please'; + await user.click(inputField); + await user.keyboard(sampleInputText); + expect(inputField.value).toBe(sampleInputText); + expect(onFilterChange).not.toHaveBeenCalled(); + + await user.click(clearButton); + expect(inputField.value).toBe(''); + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange).toHaveBeenCalledWith(''); + + await jest.advanceTimersByTimeAsync(10_000); + expect(onFilterChange).toHaveBeenCalledTimes(1); + }); + + it('Lets the user trigger clear behavior by hitting Backspace', async () => { + const user = getUser(); + const { inputField, onFilterChange } = await renderSearchBox({ + queryFilter: 'H', + }); + + await user.click(inputField); + await user.keyboard('i'); + expect(inputField.value).toBe('Hi'); + expect(onFilterChange).not.toHaveBeenCalled(); + + await user.keyboard('[Backspace][Backspace]'); + expect(inputField.value).toBe(''); + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange).toHaveBeenCalledWith(''); + + await jest.advanceTimersByTimeAsync(10_000); + expect(onFilterChange).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx index a1af15a0..d6f17b07 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx @@ -162,7 +162,8 @@ export const SearchBox = ({ return ( // Have to use aria-labelledby even though s normally provide - // accessible names automatically - "hidden" blocks the default behavior + // accessible names automatically - the hidden prop on the legend blocks the + // default behavior
; + renderListItem?: WorkspacesListProps['renderListItem']; +}>; + +function renderWorkspacesList(inputs?: RenderInputs) { + const { renderListItem, workspacesQuery } = inputs ?? {}; + + const mockContext: WorkspacesCardContext = { + headerId: "Doesn't matter", + queryFilter: "Also doesn't matter", + onFilterChange: jest.fn(), + workspacesConfig: mockCoderWorkspacesConfig, + workspacesQuery: workspacesQuery as WorkspacesQuery, + }; + + return renderInCoderEnvironment({ + children: ( + + + + ), + }); +} + +/** + * Deferring a lot of functionality tests to CoderWorkspacesCard.test.tsx + */ +describe(`${WorkspacesList.name}`, () => { + it('Allows the user to provide their own callback for iterating through each item', async () => { + const workspaceNames = ['dog', 'cat', 'bird']; + + await renderWorkspacesList({ + workspacesQuery: { + data: workspaceNames.map((name, index) => ({ + ...mockWorkspace, + name, + id: `${mockWorkspace.id}-${index}`, + })), + }, + + renderListItem: ({ workspace, index }) => ( +
  • + {workspace.name} - index {index} +
  • + ), + }); + + for (const [index, name] of workspaceNames.entries()) { + const listItem = screen.getByText( + new RegExp(`${name} - index ${index}`, 'i'), + ); + + expect(listItem).toBeInstanceOf(HTMLLIElement); + } + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index c6a92239..03860201 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -12,7 +12,7 @@ type RenderListItemInput = Readonly<{ workspaces: readonly Workspace[]; }>; -type WorkspacesListProps = Readonly< +export type WorkspacesListProps = Readonly< Omit, 'children'> & { emptyState?: ReactNode; ordered?: boolean; @@ -96,9 +96,7 @@ export const WorkspacesList = ({ {workspacesQuery.data?.length === 0 && ( <> - {emptyState !== undefined ? ( - emptyState - ) : ( + {emptyState ?? ( {repoUrl ? (
    diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx new file mode 100644 index 00000000..1803bec9 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { mockBackstageProxyEndpoint } from '../../testHelpers/mockBackstageData'; +import { WorkspacesListIcon } from './WorkspacesListIcon'; + +describe(`${WorkspacesListIcon.name}`, () => { + it('Should display a fallback UI element instead of a broken image when the image fails to load', async () => { + const workspaceName = 'blah'; + const imgPath = `${mockBackstageProxyEndpoint}/wrongUrlPal.png`; + + await renderInCoderEnvironment({ + children: ( + + ), + }); + + // Have to use test ID because the icon image itself has role "none" (it's + // decorative only and shouldn't be exposed to screen readers) + const imageIcon = screen.getByTestId('icon-image'); + + // Simulate the image automatically making a network request, but for + // whatever reason, the load fails (error code 404/500, proxy issues, etc.) + fireEvent.error(imageIcon); + + const fallbackGraphic = await screen.findByTestId('icon-fallback'); + const formattedName = workspaceName.slice(0, 1).toUpperCase(); + expect(fallbackGraphic.textContent).toBe(formattedName); + expect(imageIcon).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index c94d2ca9..23623a72 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx @@ -69,13 +69,16 @@ export const WorkspacesListIcon = ({ {...delegatedProps} > {hasError ? ( - {getFirstLetter(workspaceName)} + + {getFirstLetter(workspaceName)} + ) : ( setHasError(true)} className={`${styles.image} ${imageClassName ?? ''}`} /> diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx new file mode 100644 index 00000000..25a78136 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { mockWorkspace } from '../../testHelpers/mockCoderAppData'; +import type { Workspace } from '../../typesConstants'; +import { WorkspacesListItem } from './WorkspacesListItem'; + +type RenderInput = Readonly<{ + isOnline?: boolean; +}>; + +async function renderListItem(inputs?: RenderInput) { + const { isOnline = true } = inputs ?? {}; + + const workspace: Workspace = { + ...mockWorkspace, + latest_build: { + ...mockWorkspace.latest_build, + status: isOnline ? 'running' : 'stopped', + resources: [ + { + id: '1', + agents: [ + { + id: '2', + status: isOnline ? 'connected' : 'disconnected', + }, + ], + }, + ], + }, + }; + + return renderInCoderEnvironment({ + children: , + }); +} + +describe(`${WorkspacesListItem.name}`, () => { + it('Indicates when a workspace is online/offline', async () => { + const { unmount } = await renderListItem({ isOnline: true }); + expect(() => screen.getByText(/Online/i)).not.toThrow(); + unmount(); + + await renderListItem({ isOnline: false }); + expect(() => screen.getByText(/Offline/i)).not.toThrow(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 1131b6f2..10b8723e 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -23,17 +23,24 @@ import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; * repo URL of the current page. This is not guaranteed to be stable, and can * change over time. Do not export this without good reason. */ -const ANNOTATION_SOURCE_LOCATION_KEY = 'backstage.io/source-location'; +export const ANNOTATION_SOURCE_LOCATION_KEY = 'backstage.io/source-location'; + +/** + * The name of the repo that should be made available in the majority of + * situations + */ +export const mockRepoName = 'zombocom'; /** * The URL that will be exposed via useCoderWorkspacesConfig. This value must * have all additional parts at the end stripped off in order to make sure that * the Coder app is correctly able to download a repo for a workspace. */ -export const cleanedRepoUrl = 'https://www.github.com/zombo/com'; +export const cleanedRepoUrl = `https://www.github.com/zombocom/${mockRepoName}`; /** * The shape of URL that Backstage will parse from the entity data by default + * Pattern shared by the Source Control Managers */ export const rawRepoUrl = `${cleanedRepoUrl}/tree/main/`; @@ -68,7 +75,7 @@ export const mockEntity: BackstageEntity = { metadata: { name: 'metadata', annotations: { - [ANNOTATION_SOURCE_LOCATION_KEY]: `url:${cleanedRepoUrl}`, + [ANNOTATION_SOURCE_LOCATION_KEY]: `url:${rawRepoUrl}`, }, }, spec: { diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index e8018694..92a23594 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -1,33 +1,26 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { - MockErrorApi, - TestApiProvider, - wrapInTestApp, -} from '@backstage/test-utils'; +import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { type RenderHookOptions, type RenderHookResult, - render, renderHook, waitFor, + render, } from '@testing-library/react'; /* eslint-enable @backstage/no-undeclared-imports */ -import React, { ReactElement } from 'react'; +import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { configApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { - type EntityProviderProps, - EntityProvider, -} from '@backstage/plugin-catalog-react'; - -import { + type CoderAuth, + type CoderAuthStatus, + type CoderAppConfig, type CoderProviderProps, AuthContext, CoderAppConfigProvider, - CoderAuthStatus, } from '../components/CoderProvider'; import { getMockSourceControl, @@ -36,6 +29,7 @@ import { getMockErrorApi, getMockConfigApi, mockAuthStates, + BackstageEntity, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; @@ -114,22 +108,31 @@ export function getMockQueryClient(): QueryClient { } type MockAuthProps = Readonly< - Required & { + CoderProviderProps & { + auth?: CoderAuth; + + /** + * Shortcut property for injecting an auth object. Can conflict with the + * auth property; if both are defined, authStatus is completely ignored + */ authStatus?: CoderAuthStatus; } >; export const CoderProviderWithMockAuth = ({ children, - queryClient, appConfig, + auth, + queryClient = getMockQueryClient(), authStatus = 'authenticated', }: MockAuthProps) => { + const activeAuth = auth ?? mockAuthStates[authStatus]; + return ( - + {children} @@ -138,76 +141,6 @@ export const CoderProviderWithMockAuth = ({ ); }; -type ChildProps = EntityProviderProps; -type RenderResultWithErrorApi = ReturnType & { - errorApi: MockErrorApi; -}; - -export const renderWithEntity = ({ children }: ChildProps) => { - const mockSourceControlApi = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - - return render( - - {children} - , - ); -}; - -export const renderWithCoderProvider = ( - component: ReactElement, -): RenderResultWithErrorApi => { - const errorApi = getMockErrorApi(); - const mockQueryClient = getMockQueryClient(); - - const result = render( - - - {component} - - , - ); - - return { ...result, errorApi }; -}; - -export const renderWithCoderEntity = ({ - children, -}: ChildProps): RenderResultWithErrorApi => { - const mockErrorApi = getMockErrorApi(); - const mockSourceControl = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - const mockQueryClient = getMockQueryClient(); - - const result = render( - - - {children} - - , - ); - - return { ...result, errorApi: mockErrorApi }; -}; - type RenderHookAsCoderEntityOptions> = Omit< RenderHookOptions, 'wrapper' @@ -230,8 +163,8 @@ export const renderHookAsCoderEntity = async < const renderHookValue = renderHook(hook, { ...delegatedOptions, - wrapper: ({ children }) => - wrapInTestApp( + wrapper: ({ children }) => { + const mainMarkup = ( - - <>{children} - + {children} - , - ), + + ); + + return wrapInTestApp(mainMarkup) as unknown as typeof mainMarkup; + }, }); await waitFor(() => expect(renderHookValue.result.current).not.toBe(null)); return renderHookValue; }; + +type RenderInCoderEnvironmentInputs = Readonly<{ + children: React.ReactNode; + entity?: BackstageEntity; + appConfig?: CoderAppConfig; + queryClient?: QueryClient; + auth?: CoderAuth; +}>; + +export async function renderInCoderEnvironment({ + children, + auth, + entity = mockEntity, + queryClient = getMockQueryClient(), + appConfig = mockAppConfig, +}: RenderInCoderEnvironmentInputs) { + /** + * Tried really hard to get renderInTestApp to work, but I couldn't figure out + * how to get it set up with custom config values (mainly for testing the + * backend endpoints). + * + * Manually setting up the config API to get around that + */ + const mockErrorApi = getMockErrorApi(); + const mockSourceControl = getMockSourceControl(); + const mockConfigApi = getMockConfigApi(); + + const mainMarkup = ( + + + + {children} + + + + ); + + const wrapped = wrapInTestApp(mainMarkup) as unknown as typeof mainMarkup; + const renderOutput = render(wrapped); + const loadingIndicator = renderOutput.container.querySelector( + 'div[data-testid="progress"]', + ); + + await waitFor(() => expect(loadingIndicator).not.toBeInTheDocument()); + return renderOutput; +} From f2f0689dbb47cd956109b7fc597be6ff2eaa8c68 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 29 Mar 2024 15:20:41 -0400 Subject: [PATCH 11/33] chore: add all missing tests for backstage-plugin-coder (#101) * fix: make sure auth form has accessible name * refactor: update how mock workspace data is defined * chore: finish first search test * chore: finish test for querying * wip: commit progress on last test * fix: finalize tests * refactor: rename variable for clarity * chore: finish all network-based tests * docs: add comment for clarity * chore: add one extra test case for failing to find results * refactor: consolidate regex logic * refactor: make test logic a little more clear --- .../CoderAuthWrapper/CoderAuthInputForm.tsx | 11 +- .../CoderWorkspacesCard.test.tsx | 178 ++++++++++++++++++ .../WorkspacesList.test.tsx | 6 +- .../WorkspacesListItem.test.tsx | 6 +- .../src/hooks/useCoderWorkspacesQuery.test.ts | 35 ++-- .../src/testHelpers/mockCoderAppData.ts | 138 +++++++++++--- .../src/testHelpers/server.ts | 66 +++---- 7 files changed, 347 insertions(+), 93 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx index 40cc6784..9874500d 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -95,12 +95,21 @@ export const CoderAuthInputForm = () => { registerNewToken(newToken); }; + const formHeaderId = `${hookId}-form-header`; const legendId = `${hookId}-legend`; const authTokenInputId = `${hookId}-auth-token`; const warningBannerId = `${hookId}-warning-banner`; return ( -
    + + +

    diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx new file mode 100644 index 00000000..b99a9d69 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx @@ -0,0 +1,178 @@ +/** + * @file Defines integration tests for all sub-components in the + * CoderWorkspacesCard directory. + */ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import { mockAuthStates } from '../../testHelpers/mockBackstageData'; +import { + mockWorkspaceNoParameters, + mockWorkspaceWithMatch2, + mockWorkspacesList, +} from '../../testHelpers/mockCoderAppData'; +import { type CoderAuthStatus } from '../CoderProvider'; +import { CoderWorkspacesCard } from './CoderWorkspacesCard'; +import userEvent from '@testing-library/user-event'; + +type RenderInputs = Readonly<{ + authStatus?: CoderAuthStatus; + readEntityData?: boolean; +}>; + +function renderWorkspacesCard(input?: RenderInputs) { + const { authStatus = 'authenticated', readEntityData = false } = input ?? {}; + + return renderInCoderEnvironment({ + auth: mockAuthStates[authStatus], + children: , + }); +} + +const matchers = { + authenticationForm: /Authenticate with Coder/i, + searchTitle: /Coder Workspaces/i, + searchbox: /Search your Coder workspaces/i, + emptyState: /Use the search bar to find matching Coder workspaces/i, +} as const satisfies Record; + +describe(`${CoderWorkspacesCard.name}`, () => { + describe('General behavior', () => { + it('Shows the authentication form when the user is not authenticated', async () => { + await renderWorkspacesCard({ + authStatus: 'tokenMissing', + }); + + expect(() => { + screen.getByRole('form', { name: matchers.authenticationForm }); + }).not.toThrow(); + }); + + it('Shows the workspaces list when the user is authenticated (exposed as an accessible search landmark)', async () => { + await renderWorkspacesCard(); + + await waitFor(() => { + expect(() => { + screen.getByRole('search', { name: matchers.searchTitle }); + }).not.toThrow(); + }); + }); + + it('Shows zero workspaces when the query text matches nothing', async () => { + const entityValues = [true, false] as const; + const user = userEvent.setup(); + + for (const value of entityValues) { + const { unmount } = await renderWorkspacesCard({ + readEntityData: value, + }); + + const searchbox = await screen.findByRole('searchbox', { + name: matchers.searchbox, + }); + + await user.tripleClick(searchbox); + await user.keyboard('[Backspace]'); + await user.keyboard('I can do it - I can do it nine times'); + + await waitFor(() => { + // getAllByRole will throw if there isn't at least one node matched + const listItems = screen.queryAllByRole('listitem'); + expect(listItems.length).toBe(0); + }); + + unmount(); + } + }); + }); + + describe('With readEntityData set to false', () => { + it('Will NOT filter any workspaces by the current repo', async () => { + await renderWorkspacesCard({ readEntityData: false }); + const workspaceItems = await screen.findAllByRole('listitem'); + expect(workspaceItems.length).toEqual(mockWorkspacesList.length); + }); + + it('Lets the user filter the workspaces by their query text', async () => { + await renderWorkspacesCard({ readEntityData: false }); + const searchbox = await screen.findByRole('searchbox', { + name: matchers.searchbox, + }); + + const user = userEvent.setup(); + await user.tripleClick(searchbox); + await user.keyboard(mockWorkspaceNoParameters.name); + + // If more than one workspace matches, that throws an error + const onlyWorkspace = await screen.findByRole('listitem'); + expect(onlyWorkspace).toHaveTextContent(mockWorkspaceNoParameters.name); + }); + + it('Shows all workspaces when query text is empty', async () => { + await renderWorkspacesCard({ readEntityData: false }); + const searchbox = await screen.findByRole('searchbox', { + name: matchers.searchbox, + }); + + const user = userEvent.setup(); + await user.tripleClick(searchbox); + await user.keyboard('[Backspace]'); + + const allWorkspaces = await screen.findAllByRole('listitem'); + expect(allWorkspaces.length).toEqual(mockWorkspacesList.length); + }); + }); + + describe('With readEntityData set to true', () => { + it('Will show only the workspaces that match the current repo', async () => { + await renderWorkspacesCard({ readEntityData: true }); + const workspaceItems = await screen.findAllByRole('listitem'); + expect(workspaceItems.length).toEqual(2); + }); + + it('Lets the user filter the workspaces by their query text (on top of filtering from readEntityData)', async () => { + await renderWorkspacesCard({ readEntityData: true }); + + await waitFor(() => { + const workspaceItems = screen.getAllByRole('listitem'); + expect(workspaceItems.length).toBe(2); + }); + + const user = userEvent.setup(); + const searchbox = await screen.findByRole('searchbox', { + name: matchers.searchbox, + }); + + await user.tripleClick(searchbox); + await user.keyboard(mockWorkspaceWithMatch2.name); + + await waitFor(() => { + const newWorkspaceItems = screen.getAllByRole('listitem'); + expect(newWorkspaceItems.length).toBe(1); + }); + }); + + /** + * 2024-03-28 - MES - This is a test case to account for a previous + * limitation around querying workspaces by repo URL. + * + * This limitation no longer exists, so this test should be removed once the + * rest of the codebase is updated to support the new API endpoint for + * searching by build parameter + */ + it('Will not show any workspaces at all when the query text is empty', async () => { + await renderWorkspacesCard({ readEntityData: true }); + + const user = userEvent.setup(); + const searchbox = await screen.findByRole('searchbox', { + name: matchers.searchbox, + }); + + await user.tripleClick(searchbox); + await user.keyboard('[Backspace]'); + + const emptyState = await screen.findByText(matchers.emptyState); + expect(emptyState).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx index b8b87ddc..f2033a82 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx @@ -3,7 +3,7 @@ import { type WorkspacesListProps, WorkspacesList } from './WorkspacesList'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { CardContext, WorkspacesCardContext, WorkspacesQuery } from './Root'; import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; -import { mockWorkspace } from '../../testHelpers/mockCoderAppData'; +import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; import { Workspace } from '../../typesConstants'; import { screen } from '@testing-library/react'; @@ -42,9 +42,9 @@ describe(`${WorkspacesList.name}`, () => { await renderWorkspacesList({ workspacesQuery: { data: workspaceNames.map((name, index) => ({ - ...mockWorkspace, + ...mockWorkspaceWithMatch, name, - id: `${mockWorkspace.id}-${index}`, + id: `${mockWorkspaceWithMatch.id}-${index}`, })), }, diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx index 25a78136..03ff2623 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { mockWorkspace } from '../../testHelpers/mockCoderAppData'; +import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; import type { Workspace } from '../../typesConstants'; import { WorkspacesListItem } from './WorkspacesListItem'; @@ -13,9 +13,9 @@ async function renderListItem(inputs?: RenderInput) { const { isOnline = true } = inputs ?? {}; const workspace: Workspace = { - ...mockWorkspace, + ...mockWorkspaceWithMatch, latest_build: { - ...mockWorkspace.latest_build, + ...mockWorkspaceWithMatch.latest_build, status: isOnline ? 'running' : 'stopped', resources: [ { diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts index 0e1d9571..d29e64a5 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts @@ -3,6 +3,10 @@ import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; import { renderHookAsCoderEntity } from '../testHelpers/setup'; import { mockCoderWorkspacesConfig } from '../testHelpers/mockBackstageData'; +import { + mockWorkspaceNoParameters, + mockWorkspacesList, +} from '../testHelpers/mockCoderAppData'; beforeAll(() => { jest.useFakeTimers(); @@ -38,12 +42,22 @@ describe(`${useCoderWorkspacesQuery.name}`, () => { await jest.advanceTimersByTimeAsync(10_000); }); - /* eslint-disable-next-line jest/no-disabled-tests -- - Putting this off for the moment, because figuring out how to mock this out - without making the code fragile/flaky will probably take some time - */ - it.skip('Will filter workspaces by search criteria when it is provided', async () => { - expect.hasAssertions(); + it('Will filter workspaces by search criteria when it is provided', async () => { + const { result, rerender } = await renderHookAsCoderEntity( + ({ coderQuery }) => useCoderWorkspacesQuery({ coderQuery }), + { initialProps: { coderQuery: 'owner:me' } }, + ); + + await waitFor(() => { + expect(result.current.data?.length).toEqual(mockWorkspacesList.length); + }); + + rerender({ coderQuery: mockWorkspaceNoParameters.name }); + + await waitFor(() => { + const firstItemName = result.current.data?.[0]?.name; + expect(firstItemName).toBe(mockWorkspaceNoParameters.name); + }); }); it('Will only return workspaces for a given repo when a repoConfig is provided', async () => { @@ -54,12 +68,7 @@ describe(`${useCoderWorkspacesQuery.name}`, () => { }); }); - // This query takes a little bit longer to run and process; waitFor will - // almost always give up too early if a longer timeout isn't specified - await waitFor(() => expect(result.current.status).toBe('success'), { - timeout: 3_000, - }); - - expect(result.current.data?.length).toBe(1); + await waitFor(() => expect(result.current.status).toBe('success')); + expect(result.current.data?.length).toBe(2); }); }); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index 5a5fd50e..6e122aad 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,38 +1,120 @@ -import type { - Workspace, - WorkspaceAgent, - WorkspaceBuild, - WorkspaceBuildParameter, - WorkspaceResource, -} from '../typesConstants'; - -export const mockWorkspaceAgent: WorkspaceAgent = { - id: 'test-workspace-agent', - status: 'connected', +import type { Workspace, WorkspaceBuildParameter } from '../typesConstants'; +import { cleanedRepoUrl } from './mockBackstageData'; + +/** + * The main mock for a workspace whose repo URL matches cleanedRepoUrl + */ +export const mockWorkspaceWithMatch: Workspace = { + id: 'workspace-with-match', + name: 'Test-Workspace', + template_icon: '/emojis/dog.svg', + owner_name: 'lil brudder', + latest_build: { + id: 'workspace-with-match-build', + status: 'running', + resources: [ + { + id: 'workspace-with-match-resource', + agents: [{ id: 'test-workspace-agent', status: 'connected' }], + }, + ], + }, }; -export const mockWorkspaceResource: WorkspaceResource = { - id: 'test-workspace-resource', - agents: [mockWorkspaceAgent], +/** + * A secondary mock for a workspace whose repo URL matches cleanedRepoUrl. + * + * Mainly here for asserting that things like search functionality are able to + * return multiple values back + */ +export const mockWorkspaceWithMatch2: Workspace = { + id: 'workspace-with-match-2', + name: 'Another-Test', + template_icon: '/emojis/z.svg', + owner_name: 'Coach Z', + latest_build: { + id: 'workspace-with-match-2-build', + status: 'running', + resources: [ + { + id: 'workspace-with-match-2-resource', + agents: [{ id: 'test-workspace-agent', status: 'connected' }], + }, + ], + }, }; -export const mockWorkspaceBuild: WorkspaceBuild = { - id: 'mock-workspace-build', - resources: [mockWorkspaceResource], - status: 'running', +/** + * Mock for a workspace that has a repo URL, but the URL doesn't match + * cleanedRepoUrl + */ +export const mockWorkspaceNoMatch: Workspace = { + id: 'workspace-no-match', + name: 'No-match', + template_icon: '/emojis/star.svg', + owner_name: 'homestar runner', + latest_build: { + id: 'workspace-no-match-build', + status: 'stopped', + resources: [ + { + id: 'workspace-no-match-resource', + agents: [ + { id: 'test-workspace-agent-a', status: 'disconnected' }, + { id: 'test-workspace-agent-b', status: 'timeout' }, + ], + }, + ], + }, }; -export const mockWorkspace: Workspace = { - id: 'test-workspace', - name: 'Test-Workspace', - template_icon: '/emojis/apple.svg', +/** + * A workspace with no build parameters whatsoever + */ +export const mockWorkspaceNoParameters: Workspace = { + id: 'workspace-no-parameters', + name: 'No-parameters', + template_icon: '/emojis/cheese.png', + owner_name: 'The Cheat', + latest_build: { + id: 'workspace-no-parameters-build', + status: 'running', + resources: [ + { + id: 'workspace-no-parameters-resource', + agents: [{ id: 'test-workspace-c', status: 'timeout' }], + }, + ], + }, +}; - owner_name: 'lil brudder', +/** + * Contains a mix of different workspace variants + */ +export const mockWorkspacesList: Workspace[] = [ + mockWorkspaceWithMatch, + mockWorkspaceWithMatch2, + mockWorkspaceNoMatch, + mockWorkspaceNoParameters, +]; - latest_build: mockWorkspaceBuild, -}; +export const mockWorkspaceBuildParameters: Record< + string, + readonly WorkspaceBuildParameter[] +> = { + [mockWorkspaceWithMatch.latest_build.id]: [ + { name: 'repo_url', value: cleanedRepoUrl }, + ], + + [mockWorkspaceWithMatch2.latest_build.id]: [ + { name: 'repo_url', value: cleanedRepoUrl }, + ], + + [mockWorkspaceNoMatch.latest_build.id]: [ + { name: 'repo_url', value: 'https://www.github.com/wombo/zom' }, + ], -export const mockWorkspaceBuildParameter: WorkspaceBuildParameter = { - name: 'goofy', - value: 'a-hyuck', + [mockWorkspaceNoParameters.latest_build.id]: [ + // Intentionally kept empty + ], }; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 7749ebd5..5602241d 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -4,47 +4,34 @@ import { setupServer } from 'msw/node'; /* eslint-enable @backstage/no-undeclared-imports */ import { - mockWorkspace, - mockWorkspaceBuild, - mockWorkspaceBuildParameter, + mockWorkspacesList, + mockWorkspaceBuildParameters, } from './mockCoderAppData'; import { - cleanedRepoUrl, mockCoderAuthToken, mockBackstageProxyEndpoint as root, } from './mockBackstageData'; -import { - WorkspaceBuildParameter, - type Workspace, - WorkspacesResponse, -} from '../typesConstants'; +import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api'; -const repoBuildParamId = 'mock-repo'; - const handlers: readonly RestHandler[] = [ - rest.get(`${root}/workspaces`, (_, res, ctx) => { - const sampleWorkspaces = new Array(5) - .fill(mockWorkspace) - .map((ws, i) => { - const oneIndexed = i + 1; + rest.get(`${root}/workspaces`, (req, res, ctx) => { + const queryText = String(req.url.searchParams.get('q')); - return { - ...ws, - id: `${ws.id}-${oneIndexed}`, - name: `${ws.name}-${oneIndexed}`, - latest_build: { - ...mockWorkspaceBuild, - id: i === 0 ? repoBuildParamId : `${ws.name}-${oneIndexed}`, - }, - }; - }); + let returnedWorkspaces: Workspace[]; + if (queryText === 'owner:me') { + returnedWorkspaces = mockWorkspacesList; + } else { + returnedWorkspaces = mockWorkspacesList.filter(ws => + ws.name.includes(queryText), + ); + } return res( ctx.status(200), ctx.json({ - workspaces: sampleWorkspaces, - count: sampleWorkspaces.length, + workspaces: returnedWorkspaces, + count: returnedWorkspaces.length, }), ); }), @@ -52,25 +39,14 @@ const handlers: readonly RestHandler[] = [ rest.get( `${root}/workspacebuilds/:workspaceBuildId/parameters`, (req, res, ctx) => { - const workspaceBuildId = (req.params.workspaceBuildId ?? '') as string; - - const sampleBuildParams = new Array(5) - .fill(mockWorkspaceBuildParameter) - .map((wbp, i) => { - const oneIndexed = i + 1; - const useRepoName = i === 0 && workspaceBuildId === repoBuildParamId; + const buildId = String(req.params.workspaceBuildId); + const selectedParams = mockWorkspaceBuildParameters[buildId]; - return { - ...wbp, - name: useRepoName ? 'repo_url' : `${wbp.value}-${oneIndexed}`, - value: useRepoName ? cleanedRepoUrl : `${wbp.value}-${oneIndexed}`, - }; - }); + if (selectedParams !== undefined) { + return res(ctx.status(200), ctx.json(selectedParams)); + } - return res( - ctx.status(200), - ctx.json(sampleBuildParams), - ); + return res(ctx.status(404)); }, ), From 81502c2ab817ed4694ef055216f96a9434ec7beb Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 29 Mar 2024 17:33:13 -0400 Subject: [PATCH 12/33] fix: make sure Coder plugin indicates when a workspace is being deleted (#82) * fix: display when a workspace is being deleted * refactor: clean up code for clarity * fix: expose statuses more directly in markup * fix: add pending and failed states to output * fix: add more granularity to status logic --- .../WorkspacesListItem.tsx | 85 ++++++++++++++++--- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 801a3c1a..86904329 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -9,9 +9,9 @@ import { type Theme, makeStyles } from '@material-ui/core'; import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; -import { isWorkspaceOnline } from '../../api'; +import { getWorkspaceAgentStatuses } from '../../api'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace, WorkspaceStatus } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; import { VisuallyHidden } from '../VisuallyHidden'; @@ -24,7 +24,7 @@ type StyleKey = | 'button'; type UseStyleInputs = Readonly<{ - isOnline: boolean; + isAvailable: boolean; }>; const useStyles = makeStyles(theme => ({ @@ -84,7 +84,7 @@ const useStyles = makeStyles(theme => ({ fontSize: '16px', }, - onlineStatusLight: ({ isOnline }) => ({ + onlineStatusLight: ({ isAvailable }) => ({ display: 'block', width: theme.spacing(1), height: theme.spacing(1), @@ -93,8 +93,10 @@ const useStyles = makeStyles(theme => ({ borderStyle: 'solid', // Border color helps increase color contrast in light mode - borderColor: isOnline ? 'hsl(130deg,100%,40%)' : theme.palette.common.black, - backgroundColor: isOnline + borderColor: isAvailable + ? 'hsl(130deg,100%,40%)' + : theme.palette.common.black, + backgroundColor: isAvailable ? 'hsl(135deg,100%,77%)' : theme.palette.common.black, }), @@ -142,8 +144,11 @@ export const WorkspacesListItem = ({ const { accessUrl } = useCoderAppConfig().deployment; const anchorElementRef = useRef(null); - const isOnline = isWorkspaceOnline(workspace); - const styles = useStyles({ isOnline }); + const availabilityStatus = getAvailabilityStatus(workspace); + const styles = useStyles({ + isAvailable: + availabilityStatus === 'online' || availabilityStatus === 'pending', + }); const { name, owner_name, template_icon } = workspace; const onlineStatusId = `${hookId}-online-status`; @@ -205,8 +210,15 @@ export const WorkspacesListItem = ({ /> Workspace is - {isOnline ? 'Online' : 'Offline'} - . + {availabilityStatus === 'deleting' || + availabilityStatus === 'pending' ? ( + <>{toUppercase(availabilityStatus)}… + ) : ( + <> + {toUppercase(availabilityStatus)} + . + + )}

    @@ -226,6 +238,55 @@ export const WorkspacesListItem = ({ ); }; +const deletingStatuses: readonly WorkspaceStatus[] = ['deleting', 'deleted']; +const offlineStatuses: readonly WorkspaceStatus[] = [ + 'stopped', + 'stopping', + 'pending', + 'canceling', + 'canceled', +]; + +type AvailabilityStatus = + | 'online' + | 'offline' + | 'pending' + | 'failed' + | 'deleting'; + +function getAvailabilityStatus(workspace: Workspace): AvailabilityStatus { + const currentStatus = workspace.latest_build.status; + + if (currentStatus === 'failed') { + return 'failed'; + } + + // When a workspace is being deleted, there is a good chance that the agents + // will still show as connected/connecting. If this check isn't done before + // looking at the agent statuses, a deleting workspace might show up as online + if (deletingStatuses.includes(currentStatus)) { + return 'deleting'; + } + + if (offlineStatuses.includes(currentStatus)) { + return 'offline'; + } + + const uniqueStatuses = getWorkspaceAgentStatuses(workspace); + const isPending = + currentStatus === 'starting' || + uniqueStatuses.some(status => status === 'connecting'); + + if (isPending) { + return 'pending'; + } + + // .every will still make workspaces with no agents show as online + return uniqueStatuses.every(status => status === 'connected') + ? 'online' + : 'offline'; +} + function stopClickEventBubbling(event: MouseEvent | KeyboardEvent): void { const { nativeEvent } = event; const shouldStopBubbling = @@ -236,3 +297,7 @@ function stopClickEventBubbling(event: MouseEvent | KeyboardEvent): void { event.stopPropagation(); } } + +function toUppercase(s: string): string { + return s.slice(0, 1).toUpperCase() + s.slice(1).toLowerCase(); +} From 6c050f1ab7397ca7d4d3e8f571475a8d8953af66 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 29 Mar 2024 17:50:43 -0400 Subject: [PATCH 13/33] chore: soften error message styling for invalid/authenticating tokens (#102) * wip: commit progress on message redesign * wip: commit more style changes * wip: more style progress * chore: finish update for message * chore: add test case for dismissing functionality --- .../CoderAuthWrapper/CoderAuthInputForm.tsx | 170 +++++++++++++----- .../CoderAuthWrapper.test.tsx | 27 ++- 2 files changed, 144 insertions(+), 53 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx index 9874500d..f7e926b2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -1,4 +1,4 @@ -import React, { FormEvent } from 'react'; +import React, { type FormEvent, useState } from 'react'; import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, @@ -6,22 +6,15 @@ import { useCoderAuth, } from '../CoderProvider'; -import { Theme, makeStyles } from '@material-ui/core'; -import TextField from '@material-ui/core/TextField'; import { CoderLogo } from '../CoderLogo'; import { Link, LinkButton } from '@backstage/core-components'; import { VisuallyHidden } from '../VisuallyHidden'; +import { makeStyles } from '@material-ui/core'; +import TextField from '@material-ui/core/TextField'; +import ErrorIcon from '@material-ui/icons/ErrorOutline'; +import SyncIcon from '@material-ui/icons/Sync'; -type UseStyleInput = Readonly<{ status: CoderAuthStatus }>; -type StyleKeys = - | 'formContainer' - | 'authInputFieldset' - | 'coderLogo' - | 'authButton' - | 'warningBanner' - | 'warningBannerContainer'; - -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ formContainer: { maxWidth: '30em', marginLeft: 'auto', @@ -50,41 +43,13 @@ const useStyles = makeStyles(theme => ({ marginLeft: 'auto', marginRight: 'auto', }, - - warningBannerContainer: { - paddingTop: theme.spacing(4), - paddingLeft: theme.spacing(6), - paddingRight: theme.spacing(6), - }, - - warningBanner: ({ status }) => { - let color: string; - let backgroundColor: string; - - if (status === 'invalid') { - color = theme.palette.error.contrastText; - backgroundColor = theme.palette.banner.error; - } else { - color = theme.palette.text.primary; - backgroundColor = theme.palette.background.default; - } - - return { - color, - backgroundColor, - borderRadius: theme.shape.borderRadius, - textAlign: 'center', - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - }; - }, })); export const CoderAuthInputForm = () => { const hookId = useId(); + const styles = useStyles(); const appConfig = useCoderAppConfig(); const { status, registerNewToken } = useCoderAuth(); - const styles = useStyles({ status }); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -161,13 +126,122 @@ export const CoderAuthInputForm = () => {
    {(status === 'invalid' || status === 'authenticating') && ( -
    -
    - {status === 'invalid' && 'Invalid token'} - {status === 'authenticating' && <>Authenticating…} -
    -
    + )} ); }; + +const useInvalidStatusStyles = makeStyles(theme => ({ + warningBannerSpacer: { + paddingTop: theme.spacing(2), + }, + + warningBanner: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.default, + borderRadius: theme.shape.borderRadius, + border: `1.5px solid ${theme.palette.background.default}`, + padding: 0, + }, + + errorContent: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + columnGap: theme.spacing(1), + marginRight: 'auto', + + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(2), + paddingRight: 0, + }, + + icon: { + fontSize: '16px', + }, + + syncIcon: { + color: theme.palette.text.primary, + opacity: 0.6, + }, + + errorIcon: { + color: theme.palette.error.main, + fontSize: '16px', + }, + + dismissButton: { + border: 'none', + alignSelf: 'stretch', + padding: `0 ${theme.spacing(1.5)}px 0 ${theme.spacing(2)}px`, + color: theme.palette.text.primary, + backgroundColor: 'inherit', + lineHeight: 1, + cursor: 'pointer', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, + + '@keyframes spin': { + '100%': { + transform: 'rotate(360deg)', + }, + }, +})); + +type InvalidStatusProps = Readonly<{ + authStatus: CoderAuthStatus; + bannerId: string; +}>; + +function InvalidStatusNotifier({ authStatus, bannerId }: InvalidStatusProps) { + const [showNotification, setShowNotification] = useState(true); + const styles = useInvalidStatusStyles(); + + if (!showNotification) { + return null; + } + + return ( +
    +
    + + {authStatus === 'authenticating' && ( + <> + + Authenticating… + + )} + + {authStatus === 'invalid' && ( + <> + + Invalid token + + )} + + + +
    +
    + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx index de33394a..bf27a634 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; import type { CoderAuth, CoderAuthStatus } from '../CoderProvider'; @@ -12,13 +12,13 @@ import { CoderAuthWrapper } from './CoderAuthWrapper'; import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ - childButtonText: string; authStatus: CoderAuthStatus; + childButtonText?: string; }>; async function renderAuthWrapper({ authStatus, - childButtonText, + childButtonText = 'Default button text', }: RenderInputs) { const ejectToken = jest.fn(); const registerNewToken = jest.fn(); @@ -108,7 +108,6 @@ describe(`${CoderAuthWrapper.name}`, () => { it('Lets the user eject the current token', async () => { const { ejectToken } = await renderAuthWrapper({ authStatus: 'distrusted', - childButtonText: "I don't matter", }); const user = userEvent.setup(); @@ -174,7 +173,6 @@ describe(`${CoderAuthWrapper.name}`, () => { it('Lets the user submit a new token', async () => { const { registerNewToken } = await renderAuthWrapper({ authStatus: 'tokenMissing', - childButtonText: "I don't matter", }); /** @@ -194,5 +192,24 @@ describe(`${CoderAuthWrapper.name}`, () => { expect(registerNewToken).toHaveBeenCalledWith(mockCoderAuthToken); }); + + it('Lets the user dismiss any notifications for invalid/authenticating states', async () => { + const authStatuses: readonly CoderAuthStatus[] = [ + 'invalid', + 'authenticating', + ]; + + const user = userEvent.setup(); + for (const authStatus of authStatuses) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const dismissButton = await screen.findByRole('button', { + name: 'Dismiss', + }); + + await user.click(dismissButton); + await waitFor(() => expect(dismissButton).not.toBeInTheDocument()); + unmount(); + } + }); }); }); From ece362ae150afe1bf462a3efeb8d2dbfb305dbd1 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 4 Apr 2024 13:13:50 -0400 Subject: [PATCH 14/33] chore(coder plugin): make template names optional (#103) * wip: update type definitions and parsing logic for config values * refactor: update some code for clarity * fix: update property names in top-level config * wip: commit progress on link update * chore: finish updates for CreateWorkspaceLink * chore: add new test case for disabled state * fix: cleanup markup and text for EntityDataReminder * chore: add readEntityData as context value * refactor: rename DataEntityReminder to ReminderAcoordionItem * chore: extract core accordion item logic to parent * chore: finish initial version of ReminderAccordion * wip: commit test stubs for ReminderAccordion * chore: rename isReadingEntityData prop * chore: update mock context values in tests * wip: commit test stub for hiding cta button when there is no repo URL * chore: hide CTA button when there is no repo URL * chore: rename AccordionItem to Disclosure * chore: update tests for Disclosure * chore: remove needless hasAssertions calls * fix: update conditional logic for ReminderAccordion * fix: more accordion bug fixes * chore: finish another test case * chore: add another accordion test case * refactor: rename props for clarity * refactor: simplify condition for entity reminder * refactor: update prop for Disclosure * chore: finish all tests for accordion * refactor: update type definition for mock config * refactor: polish up accordion tests * chore: finish up all tests * fix: add missing property to mock setup to help compiler pass * refactor: move isReadingEntityData property to workspaces config * fix: add overflow-y and max height behavior to accordion * chore: polish styling for accordion * fix: add reminder accordion as exported plugin component * refactor: rename imported component to reduce visual noise when reading * fix: make no-link message more clear for button * fix: update text to account for new tooltip * docs: add page about catalog-info * docs: finish all docs updates for coder plugin * docs: add docs section for ReminderAccordion * fix: update link for documentation in UI --- .../app/src/components/catalog/EntityPage.tsx | 4 +- plugins/backstage-plugin-coder/README.md | 52 +++- .../backstage-plugin-coder/dev/DevPage.tsx | 4 +- .../docs/catalog-info.md | 59 +++++ .../backstage-plugin-coder/docs/components.md | 87 ++++--- plugins/backstage-plugin-coder/docs/hooks.md | 12 +- plugins/backstage-plugin-coder/docs/types.md | 28 +-- .../CoderAuthWrapper.test.tsx | 2 - .../CoderErrorBoundary.test.tsx | 4 +- .../CoderProvider/CoderAppConfigProvider.tsx | 21 +- .../CoderWorkspacesCard.tsx | 8 +- .../CreateWorkspaceLink.test.tsx | 52 +++- .../CreateWorkspaceLink.tsx | 79 +++++- .../EntityDataReminder.test.tsx | 34 --- .../EntityDataReminder.tsx | 114 --------- .../ExtraActionsButton.test.tsx | 14 +- .../ReminderAccordion.test.tsx | 233 ++++++++++++++++++ .../CoderWorkspacesCard/ReminderAccordion.tsx | 146 +++++++++++ .../components/CoderWorkspacesCard/Root.tsx | 23 +- .../CoderWorkspacesCard/SearchBox.test.tsx | 9 +- .../WorkspacesList.test.tsx | 38 ++- .../CoderWorkspacesCard/WorkspacesList.tsx | 6 +- .../components/CoderWorkspacesCard/index.ts | 1 + .../components/Disclosure/Disclosure.test.tsx | 68 +++++ .../src/components/Disclosure/Disclosure.tsx | 93 +++++++ .../InlineCodeSnippet/InlineCodeSnippet.tsx | 32 +++ .../hooks/useCoderWorkspacesConfig.test.ts | 1 + .../src/hooks/useCoderWorkspacesConfig.ts | 58 +++-- plugins/backstage-plugin-coder/src/plugin.ts | 12 + .../src/testHelpers/mockBackstageData.ts | 9 +- 30 files changed, 989 insertions(+), 314 deletions(-) create mode 100644 plugins/backstage-plugin-coder/docs/catalog-info.md delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 84f1e68a..6c4f9df1 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -137,8 +137,8 @@ const coderAppConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', diff --git a/plugins/backstage-plugin-coder/README.md b/plugins/backstage-plugin-coder/README.md index 93f3bdc2..eb53cb29 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -28,22 +28,23 @@ the Dev Container. yarn --cwd packages/app add @coder/backstage-plugin-coder ``` -1. Add the proxy key to your `app-config.yaml`: +2. Add the proxy key to your `app-config.yaml`: ```yaml proxy: endpoints: '/coder': - # Replace with your Coder deployment access URL and a trailing / + # Replace with your Coder deployment access URL (add a trailing slash) target: 'https://coder.example.com/' + changeOrigin: true - allowedMethods: ['GET'] + allowedMethods: ['GET'] # Additional methods will be supported soon! allowedHeaders: ['Authorization', 'Coder-Session-Token'] headers: X-Custom-Source: backstage ``` -1. Add the `CoderProvider` to the application: +3. Add the `CoderProvider` to the application: ```tsx // In packages/app/src/App.tsx @@ -58,14 +59,16 @@ the Dev Container. }, // Set the default template (and parameters) for - // catalog items. This can be overridden in the - // catalog-info.yaml for specific items. + // catalog items. Individual properties can be overridden + // by a repo's catalog-info.yaml file workspaces: { - templateName: 'devcontainers', - mode: 'manual', - // This parameter is used to filter Coder workspaces - // by a repo URL parameter. + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', + + // This property defines which parameters in your Coder + // workspace templates are used to store repository links repoUrlParamKeys: ['custom_repo', 'repo_url'], + params: { repo: 'custom', region: 'eu-helsinki', @@ -88,7 +91,7 @@ the Dev Container. **Note:** You can also wrap a single page or component with `CoderProvider` if you only need Coder in a specific part of your app. See our [API reference](./docs/README.md) (particularly the section on [the `CoderProvider` component](./docs/components.md#coderprovider)) for more details. -1. Add the `CoderWorkspacesCard` card to the entity page in your app: +4. Add the `CoderWorkspacesCard` card to the entity page in your app: ```tsx // In packages/app/src/components/catalog/EntityPage.tsx @@ -101,6 +104,33 @@ the Dev Container. ; ``` +### `app-config.yaml` files + +In addition to the above, you can define additional properties on your specific repo's `catalog-info.yaml` file. + +Example: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: python-project +spec: + type: other + lifecycle: unknown + owner: pms + + # Properties for the Coder plugin are placed here + coder: + templateName: 'devcontainers' + mode: 'auto' + params: + repo: 'custom' + region: 'us-pittsburgh' +``` + +You can find more information about what properties are available (and how they're applied) in our [`catalog-info.yaml` file documentation](./docs/catalog-info.md). + ## Roadmap This plugin is in active development. The following features are planned: diff --git a/plugins/backstage-plugin-coder/dev/DevPage.tsx b/plugins/backstage-plugin-coder/dev/DevPage.tsx index 2d82cc6d..abc24008 100644 --- a/plugins/backstage-plugin-coder/dev/DevPage.tsx +++ b/plugins/backstage-plugin-coder/dev/DevPage.tsx @@ -24,8 +24,8 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', diff --git a/plugins/backstage-plugin-coder/docs/catalog-info.md b/plugins/backstage-plugin-coder/docs/catalog-info.md new file mode 100644 index 00000000..34fd72b3 --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/catalog-info.md @@ -0,0 +1,59 @@ +# `catalog-info.yaml` files + +This file provides documentation for all properties that the Coder plugin recognizes from Backstage's [`catalog-info.yaml` files](https://backstage.io/docs/features/software-catalog/descriptor-format/). + +## Example file + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: python-project +spec: + type: other + lifecycle: unknown + owner: pms + + # Properties for the Coder plugin are placed here + coder: + templateName: 'devcontainers' + mode: 'auto' + params: + repo: 'custom' + region: 'us-pittsburgh' +``` + +All config properties are placed under the `spec.coder` property. + +## Where these properties are used + +At present, there are two main areas where these values are used: + +- [`CoderWorkspacesCard`](./components.md#coderworkspacescard) (and all sub-components) +- [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) + +## Property listing + +### `templateName` + +**Type:** Optional `string` + +This defines the name of the Coder template you would like to use when creating new workspaces from Backstage. + +**Note:** This value has overlap with the `defaultTemplateName` property defined in [`CoderAppConfig`](types.md#coderappconfig). In the event that both values are present, the YAML file's `templateName` property will always be used instead. + +### `templateName` + +**Type:** Optional union of `manual` or `auto` + +This defines the workspace creation mode that will be embedded as a URL parameter in any outgoing links to make new workspaces in your Coder deployment. (e.g.,`useCoderWorkspacesConfig`'s `creationUrl` property) + +**Note:** This value has overlap with the `defaultMode` property defined in [`CoderAppConfig`](types.md#coderappconfig). In the event that both values are present, the YAML file's `mode` property will always be used instead. + +### `params` + +**Type:** Optional JSON object of string values (equivalent to TypeScript's `Record`) + +This allows you to define additional Coder workspace parameter values that should be passed along to any outgoing URLs for making new workspaces in your Coder deployment. These values are fully dynamic, and unfortunately, cannot have much type safety. + +**Note:** The properties from the `params` property are automatically merged with the properties defined via `CoderAppConfig`'s `params` property. In the event of any key conflicts, the params from `catalog-info.yaml` will always win. diff --git a/plugins/backstage-plugin-coder/docs/components.md b/plugins/backstage-plugin-coder/docs/components.md index 5b555915..9241a11b 100644 --- a/plugins/backstage-plugin-coder/docs/components.md +++ b/plugins/backstage-plugin-coder/docs/components.md @@ -26,7 +26,7 @@ This component is designed to simplify authentication checks for other component ```tsx type Props = Readonly< PropsWithChildren<{ - type: 'card'; + type: 'card'; // More types to be added soon! }> >; @@ -86,7 +86,7 @@ function YourComponent() { return

    Will never reach this code

    ; } - +Something broke. Sorry!

    }>
    ; ``` @@ -133,7 +133,7 @@ function YourComponent() { return (
      {queryState.data?.map(workspace => ( -
    • {workspace.owner_name}
    • +
    • {workspace.name}
    • ))}
    ); @@ -145,8 +145,8 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', @@ -162,12 +162,12 @@ const appConfig: CoderAppConfig = { ### Throws -- Does not throw +- Only throws if `appConfig` is not provided (but this is also caught at the type level) ### Notes - This component was deliberately designed to be agnostic of as many Backstage APIs as possible - it can be placed as high as the top of the app, or treated as a wrapper around a specific plugin component. - - That said, it is recommended that only have one instance of `CoderProvider` per Backstage deployment. Multiple `CoderProvider` component instances could interfere with each other and accidentally fragment caching state + - That said, it is recommended that you only have one instance of `CoderProvider` per Backstage deployment. Multiple `CoderProvider` component instances could interfere with each other and accidentally fragment caching state - If you are already using TanStack Query in your deployment, you can provide your own `QueryClient` value via the `queryClient` prop. - If not specified, `CoderProvider` will use its own client - Even if you aren't using TanStack Query anywhere else, you could consider adding your own client to configure it with more specific settings @@ -176,11 +176,11 @@ const appConfig: CoderAppConfig = { ## `CoderWorkspacesCard` -Allows you to search for and display Coder workspaces that the currently-authenticated user has access to. The component handles all data-fetching, caching +Allows you to search for and display Coder workspaces that the currently-authenticated user has access to. The component handles all data-fetching, caching, and displaying of workspaces. Has two "modes" – one where the component has access to all Coder workspaces for the user, and one where the component is aware of entity data and filters workspaces to those that match the currently-open repo page. See sample usage for examples. -All "pieces" of the component are also available as modular sub-components that can be imported and composed together individually. +All "pieces" of the component are also available as modular sub-components that can be imported and composed together individually. `CoderWorkspacesCard` represents a pre-configured version that is plug-and-play. ### Type signature @@ -216,7 +216,7 @@ const appConfig: CoderAppConfig = { ``` In "aware mode" – the component only displays workspaces that -match the repo data for the currently-open entity page: +match the repo data for the currently-open entity page, but in exchange, it must always be placed inside a Backstage component that has access to entity data (e.g., `EntityLayout`): ```tsx const appConfig: CoderAppConfig = { @@ -270,13 +270,15 @@ function YourComponent() { ## `CoderWorkspacesCard.CreateWorkspacesLink` -A link-button for creating new workspaces. Clicking this link will take you to "create workspace page" in your Coder deployment, with as many fields filled out as possible. +A link-button for creating new workspaces. Clicking this link will take you to "create workspace page" in your Coder deployment, with as many fields filled out as possible (see notes for exceptions). ### Type definition ```tsx +// All Tooltip-based props come from the type definitions for +// the MUI `Tooltip` component type Props = { - tooltipText?: string; + tooltipText?: string | ReactElement; tooltipProps?: Omit; tooltipRef?: ForwardedRef; @@ -290,14 +292,13 @@ declare function CreateWorkspacesLink( ): JSX.Element; ``` -All Tooltip-based props come from the type definitions for the MUI `Tooltip` component. - ### Throws - Will throw a render error if called outside of either a `CoderProvider` or `CoderWorkspacesCard.Root` ### Notes +- If no workspace creation URL could be generated, this component will not let you create a new workspace. This can happen when the `CoderAppConfig` does not have a `defaultTemplateName` property, and the `catalog-info.yaml` file also does not have a `templateName` - If `readEntityData` is `true` in `CoderWorkspacesCard.Root`: this component will include YAML properties parsed from the current page's entity data. ## `CoderWorkspacesCard.ExtraActionsButton` @@ -305,11 +306,13 @@ All Tooltip-based props come from the type definitions for the MUI `Tooltip` com A contextual menu of additional tertiary actions that can be performed for workspaces. Current actions: - Refresh workspaces list -- Eject token +- Unlinking the current Coder session token ### Type definition ```tsx +// All Tooltip- and Menu-based props come from the type definitions +// for the MUI Tooltip and Menu components. type ExtraActionsButtonProps = Omit< ButtonHTMLAttributes, 'id' | 'aria-controls' @@ -342,8 +345,6 @@ declare function ExtraActionsButton( ): JSX.Element; ``` -All Tooltip- and Menu-based props come from the type definitions for the MUI `Tooltip` and `Menu` components. - ### Throws - Will throw a render error if called outside of either a `CoderProvider` or `CoderWorkspacesCard.Root` @@ -351,7 +352,7 @@ All Tooltip- and Menu-based props come from the type definitions for the MUI `To ### Notes - When the menu opens, the first item of the list will auto-focus -- While the menu is open, you can navigate through items with the Up and Down arrow keys on the keyboard. These instructions are available for screen readers to announce +- While the menu is open, you can navigate through items with the Up and Down arrow keys on the keyboard. Reminder instructions are also available for screen readers to announce ## `CoderWorkspacesCard.HeaderRow` @@ -389,36 +390,35 @@ declare function HeaderGroup( - If `headerLevel` is not specified, the component will default to `h2` - If `fullBleedLayout` is `true`, the component will exert negative horizontal margins to fill out its parent -- If `activeRepoFilteringText` will only display if the value of `readEntityData` in `CoderWorkspacesCard.Root` is `true` +- `activeRepoFilteringText` will only display if the value of `readEntityData` in `CoderWorkspacesCard.Root` is `true`. The component automatically uses its own text if the prop is not specified. ## `CoderWorkspacesCard.Root` -Wrapper that acts as a context provider for all other sub-components in `CoderWorkspacesCard` – does not define any components that will render to HTML. +Wrapper that acts as a context provider for all other sub-components in `CoderWorkspacesCard` – defines a very minimal set of unstyled HTML components that are necessary only for screen reader support. ### Type definition ```tsx -type WorkspacesCardContext = { - queryFilter: string; - onFilterChange: (newFilter: string) => void; - workspacesQuery: UseQueryResult; - workspacesConfig: CoderWorkspacesConfig; - headerId: string; -}; +type Props = Readonly<{ + queryFilter?: string; + defaultQueryFilter?: string; + onFilterChange?: (newFilter: string) => void; + readEntityData?: boolean; + + // Also supports all props from the native HTMLDivElement + // component, except "id" and "aria-controls" +}>; declare function Root(props: Props): JSX.Element; ``` -All props mirror those returned by [`useWorkspacesCardContext`](./hooks.md#useworkspacescardcontext) - ### Throws - Will throw a render error if called outside of a `CoderProvider` ### Notes -- If `entityConfig` is defined, the Root will auto-filter all workspaces down to those that match the repo for the currently-opened entity page -- The key for `entityConfig` is not optional – even if it isn't defined, it must be explicitly passed an `undefined` value +- The value of `readEntityData` will cause the component to flip between the two modes mentioned in the documentation for [`CoderWorkspacesCard`](#coderworkspacescard). ## `CoderWorkspacesCard.SearchBox` @@ -448,7 +448,7 @@ declare function SearchBox(props: Props): JSX.Element; ### Notes -- The logic for processing user input into a new workspaces query is automatically debounced to wait 400ms. +- The logic for processing user input into a new workspaces query is automatically debounced. ## `CoderWorkspacesCard.WorkspacesList` @@ -544,3 +544,26 @@ declare function WorkspaceListItem(props: Props): JSX.Element; ### Notes - Supports full link-like functionality (right-clicking and middle-clicking to open in a new tab, etc.) + +## `CoderWorkspacesCard.ReminderAccordion` + +An accordion that will conditionally display additional help information in the event of a likely setup error. + +### Type definition + +```tsx +type ReminderAccordionProps = Readonly<{ + canShowEntityReminder?: boolean; + canShowTemplateNameReminder?: boolean; +}>; + +declare function ReminderAccordion(props: ReminderAccordionProps): JSX.Element; +``` + +### Throws + +- Will throw a render error if mounted outside of `CoderWorkspacesCard.Root` or `CoderProvider`. + +### Notes + +- All `canShow` props allow you to disable specific help messages. If any are set to `false`, their corresponding info block will **never** render. If set to `true` (and all will default to `true` if not specified), they will only appear when a likely setup error has been detected. diff --git a/plugins/backstage-plugin-coder/docs/hooks.md b/plugins/backstage-plugin-coder/docs/hooks.md index 282fba6f..c02ba4c0 100644 --- a/plugins/backstage-plugin-coder/docs/hooks.md +++ b/plugins/backstage-plugin-coder/docs/hooks.md @@ -30,7 +30,7 @@ declare function useCoderWorkspacesConfig( ```tsx function YourComponent() { - const config = useCoderWorkspacesConfig(); + const config = useCoderWorkspacesConfig({ readEntityData: true }); return

    Your repo URL is {config.repoUrl}

    ; } @@ -62,14 +62,14 @@ const serviceEntityPage = ( ### Notes -- The type definition for `CoderWorkspacesConfig` [can be found here](./types.md#coderworkspacesconfig). That section also includes info on the heuristic used for compiling the data +- The type definition for `CoderWorkspacesConfig` [can be found here](./types.md#coderworkspacesconfig). That section also includes info on the heuristic used for compiling the data. - The value of `readEntityData` determines the "mode" that the workspace operates in. If the value is `false`/`undefined`, the component will act as a general list of workspaces that isn't aware of Backstage APIs. If the value is `true`, the hook will also read Backstage data during the compilation step. - The hook tries to ensure that the returned value maintains a stable memory reference as much as possible, if you ever need to use that value in other React hooks that use dependency arrays (e.g., `useEffect`, `useCallback`) ## `useCoderWorkspacesQuery` This hook gives you access to all workspaces that match a given query string. If -[`workspacesConfig`](#usecoderworkspacesconfig) is defined via `options`, and that config has a defined `repoUrl`, the workspaces returned will be filtered down further to only those that match the the repo. +[`workspacesConfig`](#usecoderworkspacesconfig) is defined via `options`, and that config has a defined `repoUrl` property, the workspaces returned will be filtered down further to only those that match the the repo. ### Type signature @@ -88,9 +88,9 @@ declare function useCoderWorkspacesConfig( ```tsx function YourComponent() { - const [filter, setFilter] = useState('owner:me'); + const [coderQuery, setCoderQuery] = useState('owner:me'); const workspacesConfig = useCoderWorkspacesConfig({ readEntityData: true }); - const queryState = useCoderWorkspacesQuery({ filter, workspacesConfig }); + const queryState = useCoderWorkspacesQuery({ coderQuery, workspacesConfig }); return ( <> @@ -130,7 +130,7 @@ const coderAppConfig: CoderAppConfig = { 1. The user is not currently authenticated (We recommend wrapping your component inside [`CoderAuthWrapper`](./components.md#coderauthwrapper) to make these checks easier) 2. If `repoConfig` is passed in via `options`: when the value of `coderQuery` is an empty string - The `workspacesConfig` property is the return type of [`useCoderWorkspacesConfig`](#usecoderworkspacesconfig) - - The only way to get automatically-filtered results is by (1) passing in a workspaces config value, and (2) ensuring that config has a `repoUrl` property of type string (it can sometimes be `undefined`, depending on built-in Backstage APIs). + - The only way to get workspace results that are automatically filtered by repo URL is by (1) passing in a workspaces config value, and (2) ensuring that config has a `repoUrl` property of type string (it can sometimes be `undefined`, depending on built-in Backstage APIs). ## `useWorkspacesCardContext` diff --git a/plugins/backstage-plugin-coder/docs/types.md b/plugins/backstage-plugin-coder/docs/types.md index 6caf7cd9..263f9872 100644 --- a/plugins/backstage-plugin-coder/docs/types.md +++ b/plugins/backstage-plugin-coder/docs/types.md @@ -2,7 +2,7 @@ ## General notes -- All type definitions for the Coder plugin are defined as type aliases and not interfaces, to prevent the risk of accidental interface merging. If you need to extend from one of our types, you can do it in one of two ways: +- All exported type definitions for the Coder plugin are defined as type aliases and not interfaces, to prevent the risk of accidental interface merging. If you need to extend from one of our types, you can do it in one of two ways: ```tsx // Type intersection @@ -28,15 +28,15 @@ Defines a set of configuration options for integrating Backstage with Coder. Primarily has two main uses: 1. Defining a centralized source of truth for certain Coder configuration options (such as which workspace parameters should be used for injecting repo URL values) -2. Defining "fallback" workspace parameters when a repository entity either doesn't have a `catalog-info.yaml` file at all, or only specifies a handful of properties. +2. Defining "fallback" workspace parameters when a repository entity either doesn't have a [`catalog-info.yaml` file](./catalog-info.md) at all, or only specifies a handful of properties. ### Type definition ```tsx type CoderAppConfig = Readonly<{ workspaces: Readonly<{ - templateName: string; - mode?: 'auto' | 'manual' | undefined; + defaultTemplateName?: string; + defaultMode?: 'auto' | 'manual' | undefined; params?: Record; repoUrlParamKeys: readonly [string, ...string[]]; }>; @@ -54,10 +54,10 @@ See example for [`CoderProvider`](./components.md#coderprovider) ### Notes - `accessUrl` is the URL pointing at your specific Coder deployment -- `templateName` refers to the name of the Coder template that you wish to use as default for creating workspaces -- If `mode` is not specified, the plugin will default to a value of `manual` +- `defaultTemplateName` refers to the name of the Coder template that you wish to use as default for creating workspaces. If this is not provided (and there is no `templateName` available from the `catalog-info.yaml` file, you will not be able to create new workspaces from Backstage) +- If `defaultMode` is not specified, the plugin will default to a value of `manual` - `repoUrlParamKeys` is defined as a non-empty array – there must be at least one element inside it. -- For more info on how this type is used within the plugin, see [`CoderWorkspacesConfig`](./types.md#coderworkspacesconfig) and [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) +- For more info on how this type is used within the plugin, see [`CoderWorkspacesConfig`](#coderworkspacesconfig) and [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) ## `CoderWorkspacesConfig` @@ -72,11 +72,11 @@ Represents the result of compiling Coder plugin configuration data. The main sou ```tsx type CoderWorkspacesConfig = Readonly<{ mode: 'manual' | 'auto'; + templateName: string | undefined; params: Record; creationUrl: string; repoUrl: string | undefined; repoUrlParamKeys: [string, ...string[]][]; - templateName: string; }>; ``` @@ -91,8 +91,8 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers-a', - mode: 'manual', + defaultTemplateName: 'devcontainers-config', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', @@ -113,7 +113,7 @@ spec: lifecycle: unknown owner: pms coder: - templateName: 'devcontainers-b' + templateName: 'devcontainers-yaml' mode: 'auto' params: repo: 'custom' @@ -132,13 +132,13 @@ const config: CoderWorkspacesConfig = { repo_url: 'https://github.com/Parkreiner/python-project/', }, repoUrlParamKeys: ['custom_repo', 'repo_url'], - templateName: 'devcontainers', + templateName: 'devcontainers-yaml', repoUrl: 'https://github.com/Parkreiner/python-project/', // Other URL parameters will be included in real code // but were stripped out for this example creationUrl: - 'https://dev.coder.com/templates/devcontainers-b/workspace?mode=auto', + 'https://dev.coder.com/templates/devcontainers-yaml/workspace?mode=auto', }; ``` @@ -148,7 +148,7 @@ const config: CoderWorkspacesConfig = { - The value of the `repoUrl` property is derived from [Backstage's `getEntitySourceLocation`](https://backstage.io/docs/reference/plugin-catalog-react.getentitysourcelocation/), which does not guarantee that a URL will always be defined. - This is the current order of operations used to reconcile param data between `CoderAppConfig`, `catalog-info.yaml`, and the entity location data: 1. Start with an empty `Record` value - 2. Populate the record with the data from `CoderAppConfig` + 2. Populate the record with the data from `CoderAppConfig`. If there are any property names that start with `default`, those will be stripped out (e.g., `defaultTemplateName` will be injected as `templateName`) 3. Go through all properties parsed from `catalog-info.yaml` and inject those. If the properties are already defined, overwrite them 4. Grab the repo URL from the entity's location fields. 5. For each key in `CoderAppConfig`'s `workspaces.repoUrlParamKeys` property, take that key, and inject it as a key-value pair, using the URL as the value. If the key already exists, always override it with the URL diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx index bf27a634..43199c04 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx @@ -166,8 +166,6 @@ describe(`${CoderAuthWrapper.name}`, () => { unmount(); } - - expect.hasAssertions(); }); it('Lets the user submit a new token', async () => { diff --git a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx index 5245cc4c..734defb0 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx @@ -47,8 +47,8 @@ function setupBoundaryTest(component: ReactElement) { describe(`${CoderErrorBoundary.name}`, () => { it('Displays a fallback UI when a rendering error is encountered', () => { setupBoundaryTest(); - screen.getByText(fallbackText); - expect.hasAssertions(); + const fallbackUi = screen.getByText(fallbackText); + expect(fallbackUi).toBeInTheDocument(); }); it('Exposes rendering errors to Backstage Error API', () => { diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx index 5d383be6..e3422292 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx @@ -3,23 +3,24 @@ import React, { createContext, useContext, } from 'react'; - -import type { YamlConfig } from '../../hooks/useCoderWorkspacesConfig'; +import type { WorkspaceCreationMode } from '../../hooks/useCoderWorkspacesConfig'; export type CoderAppConfig = Readonly<{ deployment: Readonly<{ accessUrl: string; }>; - workspaces: Readonly< - Exclude & { - // Only specified explicitly to make templateName required - templateName: string; + // Type is meant to be used with YamlConfig from useCoderWorkspacesConfig; + // not using a mapped type because there's just enough differences that + // maintaining a relationship that way would be a nightmare of ternaries + workspaces: Readonly<{ + defaultMode?: WorkspaceCreationMode; + defaultTemplateName?: string; + params?: Record; - // Defined like this to ensure array always has at least one element - repoUrlParamKeys: readonly [string, ...string[]]; - } - >; + // Defined like this to ensure array always has at least one element + repoUrlParamKeys: readonly [string, ...string[]]; + }>; }>; const AppConfigContext = createContext(null); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx index 64bff808..ac53b0f0 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx @@ -7,6 +7,7 @@ import { SearchBox } from './SearchBox'; import { WorkspacesList } from './WorkspacesList'; import { CreateWorkspaceLink } from './CreateWorkspaceLink'; import { ExtraActionsButton } from './ExtraActionsButton'; +import { ReminderAccordion } from './ReminderAccordion'; const useStyles = makeStyles(theme => ({ searchWrapper: { @@ -15,9 +16,9 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CoderWorkspacesCard = ( - props: Omit, -) => { +type Props = Omit; + +export const CoderWorkspacesCard = (props: Props) => { const styles = useStyles(); return ( @@ -37,6 +38,7 @@ export const CoderWorkspacesCard = (
    + ); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx index b26c86f1..6c219531 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx @@ -1,17 +1,38 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { mockAppConfig } from '../../testHelpers/mockBackstageData'; +import { + mockAppConfig, + mockCoderWorkspacesConfig, +} from '../../testHelpers/mockBackstageData'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { Root } from './Root'; +import { CardContext, WorkspacesCardContext } from './Root'; import { CreateWorkspaceLink } from './CreateWorkspaceLink'; +import type { CoderWorkspacesConfig } from '../../hooks/useCoderWorkspacesConfig'; + +type RenderInputs = Readonly<{ + hasTemplateName?: boolean; +}>; + +function render(inputs?: RenderInputs) { + const { hasTemplateName = true } = inputs ?? {}; + + const mockWorkspacesConfig: CoderWorkspacesConfig = { + ...mockCoderWorkspacesConfig, + creationUrl: hasTemplateName + ? mockCoderWorkspacesConfig.creationUrl + : undefined, + }; + + const mockContextValue: Partial = { + workspacesConfig: mockWorkspacesConfig, + }; -function render() { return renderInCoderEnvironment({ children: ( - + - + ), }); } @@ -37,4 +58,25 @@ describe(`${CreateWorkspaceLink.name}`, () => { const tooltip = await screen.findByText('Add a new workspace'); expect(tooltip).toBeInTheDocument(); }); + + it('Will be disabled and will indicate to the user when there is no usable templateName value', async () => { + await render({ hasTemplateName: false }); + const link = screen.getByRole('link'); + + // Check that the link is "disabled" properly (see main component file for + // a link to resource explaining edge cases). Can't assert toBeDisabled, + // because links don't support the disabled attribute; also can't check + // the .role and .ariaDisabled properties on the link variable, because even + // though they exist in the output, RTL doesn't correctly pass them through. + // This is a niche edge case - have to check properties on the raw HTML node + expect(link.href).toBe(''); + expect(link.getAttribute('role')).toBe('link'); + expect(link.getAttribute('aria-disabled')).toBe('true'); + + // Make sure tooltip is also updated + const user = userEvent.setup(); + await user.hover(link); + const tooltip = await screen.findByText(/Please add a template name value/); + expect(tooltip).toBeInTheDocument(); + }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx index 10c8fb86..a0a1ab84 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx @@ -1,36 +1,57 @@ -import React, { type AnchorHTMLAttributes, type ForwardedRef } from 'react'; -import { makeStyles } from '@material-ui/core'; +import React, { + type AnchorHTMLAttributes, + type ForwardedRef, + type ReactElement, +} from 'react'; +import { type Theme, makeStyles } from '@material-ui/core'; import { useWorkspacesCardContext } from './Root'; import { VisuallyHidden } from '../VisuallyHidden'; import AddIcon from '@material-ui/icons/AddCircleOutline'; import Tooltip, { type TooltipProps } from '@material-ui/core/Tooltip'; -const useStyles = makeStyles(theme => { +type StyleInput = Readonly<{ + canCreateWorkspace: boolean; +}>; + +type StyleKeys = 'root' | 'noLinkTooltipContainer'; + +const useStyles = makeStyles(theme => { const padding = theme.spacing(0.5); return { - root: { + root: ({ canCreateWorkspace }) => ({ padding, width: theme.spacing(4) + padding, height: theme.spacing(4) + padding, + cursor: 'pointer', display: 'flex', justifyContent: 'center', alignItems: 'center', backgroundColor: 'inherit', borderRadius: '9999px', lineHeight: 1, + color: canCreateWorkspace + ? theme.palette.text.primary + : theme.palette.text.disabled, '&:hover': { - backgroundColor: theme.palette.action.hover, + backgroundColor: canCreateWorkspace + ? theme.palette.action.hover + : 'inherit', }, + }), + + noLinkTooltipContainer: { + display: 'block', + maxWidth: '24em', }, }; }); type CreateButtonLinkProps = Readonly< - AnchorHTMLAttributes & { - tooltipText?: string; + Omit, 'aria-disabled'> & { + tooltipText?: string | ReactElement; tooltipProps?: Omit; tooltipRef?: ForwardedRef; } @@ -45,22 +66,58 @@ export const CreateWorkspaceLink = ({ tooltipProps = {}, ...delegatedProps }: CreateButtonLinkProps) => { - const styles = useStyles(); const { workspacesConfig } = useWorkspacesCardContext(); + const canCreateWorkspace = Boolean(workspacesConfig.creationUrl); + const styles = useStyles({ canCreateWorkspace }); return ( - + + Please add a template name value. More info available in the + accordion at the bottom of this widget. + + ) + } + {...tooltipProps} + > + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- + Some browsers will render out elements as having no role when the + href value is undefined or an empty string. Need to make sure that the + link role is always defined, no matter what. The ESLint rule is wrong + here. */} {children ?? } - {tooltipText} - {target === '_blank' && <> (Link opens in new tab)} + {canCreateWorkspace ? ( + <> + {tooltipText} + {target === '_blank' && <> (Link opens in new tab)} + + ) : ( + <> + This component does not have a usable template name. Please see + the disclosure section in this widget for steps on adding this + information. + + )} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx deleted file mode 100644 index 61536c72..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { Root } from './Root'; -import { EntityDataReminder } from './EntityDataReminder'; - -function render() { - return renderInCoderEnvironment({ - children: ( - - - - ), - }); -} - -describe(`${EntityDataReminder.name}`, () => { - it('Will toggle between showing/hiding the disclosure info when the user clicks it', async () => { - await render(); - const user = userEvent.setup(); - const disclosureButton = screen.getByRole('button', { - name: /Why am I seeing all workspaces\?/, - }); - - await user.click(disclosureButton); - const disclosureInfo = await screen.findByText( - /This component displays all workspaces when the entity has no repo URL to filter by/, - ); - - await user.click(disclosureButton); - expect(disclosureInfo).not.toBeInTheDocument(); - }); -}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx deleted file mode 100644 index c6335d85..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState } from 'react'; -import { useId } from '../../hooks/hookPolyfills'; -import { Theme, makeStyles } from '@material-ui/core'; -import { VisuallyHidden } from '../VisuallyHidden'; -import { useWorkspacesCardContext } from './Root'; - -type UseStyleProps = Readonly<{ - hasData: boolean; -}>; - -type UseStyleKeys = - | 'root' - | 'button' - | 'disclosureTriangle' - | 'disclosureBody' - | 'snippet'; - -const useStyles = makeStyles(theme => ({ - root: ({ hasData }) => ({ - paddingTop: theme.spacing(1), - borderTop: hasData ? 'none' : `1px solid ${theme.palette.divider}`, - }), - - button: { - width: '100%', - textAlign: 'left', - color: theme.palette.text.primary, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(1), - border: 'none', - borderRadius: theme.shape.borderRadius, - fontSize: theme.typography.body2.fontSize, - cursor: 'pointer', - - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, - - disclosureTriangle: { - display: 'inline-block', - textAlign: 'right', - width: theme.spacing(2.25), - }, - - disclosureBody: { - margin: 0, - padding: `${theme.spacing(0.5)}px ${theme.spacing(3.5)}px 0 ${theme.spacing( - 3.75, - )}px`, - }, - - snippet: { - color: theme.palette.text.primary, - borderRadius: theme.spacing(0.5), - padding: `${theme.spacing(0.2)}px ${theme.spacing(1)}px`, - backgroundColor: () => { - const defaultBackgroundColor = theme.palette.background.default; - const isDefaultSpotifyLightTheme = - defaultBackgroundColor.toUpperCase() === '#F8F8F8'; - - return isDefaultSpotifyLightTheme - ? 'hsl(0deg,0%,93%)' - : defaultBackgroundColor; - }, - }, -})); - -export const EntityDataReminder = () => { - const [isExpanded, setIsExpanded] = useState(false); - const { workspacesQuery } = useWorkspacesCardContext(); - const styles = useStyles({ hasData: workspacesQuery.data !== undefined }); - - const hookId = useId(); - const disclosureBodyId = `${hookId}-disclosure-body`; - - // Might be worth revisiting the markup here to try implementing this - // functionality with and elements. Would likely clean up - // the component code a ton but might reduce control over screen reader output - return ( -
    - - - {isExpanded && ( -

    - This component displays all workspaces when the entity has no repo URL - to filter by. Consider disabling{' '} - readEntityData (details in our{' '} - - documentation - (link opens in new tab) - - ). -

    - )} -
    - ); -}; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx index 732a859d..008d931a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx @@ -39,21 +39,17 @@ async function renderButton({ buttonText }: RenderInputs) { * @todo Research how to test dependencies on useQuery */ const refetch = jest.fn(); - const mockWorkspacesQuery = { - refetch, - } as unknown as WorkspacesCardContext['workspacesQuery']; - const mockContext: WorkspacesCardContext = { - headerId: "Doesn't matter", - queryFilter: "Doesn't matter", - onFilterChange: jest.fn(), + const mockContext: Partial = { workspacesConfig: mockCoderWorkspacesConfig, - workspacesQuery: mockWorkspacesQuery, + workspacesQuery: { + refetch, + } as unknown as WorkspacesCardContext['workspacesQuery'], }; const renderOutput = await renderInCoderEnvironment({ auth, children: ( - + ), diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx new file mode 100644 index 00000000..0ae1d918 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import type { Workspace } from '../../typesConstants'; +import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; +import { + type WorkspacesCardContext, + type WorkspacesQuery, + CardContext, +} from './Root'; +import { + type ReminderAccordionProps, + ReminderAccordion, +} from './ReminderAccordion'; + +type RenderInputs = Readonly< + ReminderAccordionProps & { + isReadingEntityData?: boolean; + repoUrl?: undefined | string; + creationUrl?: undefined | string; + queryData?: undefined | readonly Workspace[]; + } +>; + +function renderAccordion(inputs?: RenderInputs) { + const { + repoUrl, + creationUrl, + queryData = [], + isReadingEntityData = true, + canShowEntityReminder = true, + canShowTemplateNameReminder = true, + } = inputs ?? {}; + + const mockContext: Partial = { + workspacesConfig: { + ...mockCoderWorkspacesConfig, + repoUrl, + creationUrl, + isReadingEntityData, + }, + workspacesQuery: { + data: queryData, + } as WorkspacesQuery, + }; + + return renderInCoderEnvironment({ + children: ( + + + + ), + }); +} + +const matchers = { + toggles: { + entity: /Why am I not seeing any workspaces\?/i, + templateName: /Why can't I make a new workspace\?/, + }, + bodyText: { + entity: /^This component only displays all workspaces when/, + templateName: + /^This component cannot make a new workspace without a template name value/, + }, +} as const satisfies Record>; + +describe(`${ReminderAccordion.name}`, () => { + describe('General behavior', () => { + it('Lets the user open a single accordion item', async () => { + await renderAccordion(); + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + + const user = userEvent.setup(); + await user.click(entityToggle); + + const entityText = await screen.findByText(matchers.bodyText.entity); + expect(entityText).toBeInTheDocument(); + }); + + it('Will close an open accordion item when that item is clicked', async () => { + await renderAccordion(); + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + + const user = userEvent.setup(); + await user.click(entityToggle); + + const entityText = await screen.findByText(matchers.bodyText.entity); + await user.click(entityToggle); + expect(entityText).not.toBeInTheDocument(); + }); + + it('Only lets one accordion item be open at a time', async () => { + await renderAccordion(); + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + const templateNameToggle = await screen.findByRole('button', { + name: matchers.toggles.templateName, + }); + + const user = userEvent.setup(); + await user.click(entityToggle); + + const entityText = await screen.findByText(matchers.bodyText.entity); + expect(entityText).toBeInTheDocument(); + + await user.click(templateNameToggle); + expect(entityText).not.toBeInTheDocument(); + + const templateText = await screen.findByText( + matchers.bodyText.templateName, + ); + expect(templateText).toBeInTheDocument(); + }); + }); + + describe('Conditionally displaying items', () => { + it('Lets the user conditionally hide accordion items based on props', async () => { + type Configuration = Readonly<{ + props: ReminderAccordionProps; + expectedItemCount: number; + }>; + + const configurations: readonly Configuration[] = [ + { + expectedItemCount: 0, + props: { + canShowEntityReminder: false, + canShowTemplateNameReminder: false, + }, + }, + { + expectedItemCount: 1, + props: { + canShowEntityReminder: false, + canShowTemplateNameReminder: true, + }, + }, + { + expectedItemCount: 1, + props: { + canShowEntityReminder: true, + canShowTemplateNameReminder: false, + }, + }, + ]; + + for (const config of configurations) { + const { unmount } = await renderAccordion(config.props); + const accordionItems = screen.queryAllByRole('button'); + + expect(accordionItems.length).toBe(config.expectedItemCount); + unmount(); + } + }); + + it('Will NOT display the template name reminder if there is a creation URL', async () => { + await renderAccordion({ + creationUrl: mockCoderWorkspacesConfig.creationUrl, + canShowTemplateNameReminder: true, + }); + + const templateToggle = screen.queryByRole('button', { + name: matchers.toggles.templateName, + }); + + expect(templateToggle).not.toBeInTheDocument(); + }); + + /** + * Assuming that the user hasn't disabled showing the reminder at all, it + * will only appear when both of these are true: + * 1. The component is set up to read entity data + * 2. There is no repo URL that could be parsed from the entity data + */ + it('Will only display the entity data reminder when appropriate', async () => { + type Config = Readonly<{ + isReadingEntityData: boolean; + repoUrl: string | undefined; + }>; + + const doNotDisplayConfigs: readonly Config[] = [ + { + isReadingEntityData: false, + repoUrl: mockCoderWorkspacesConfig.repoUrl, + }, + { + isReadingEntityData: false, + repoUrl: undefined, + }, + { + isReadingEntityData: true, + repoUrl: mockCoderWorkspacesConfig.repoUrl, + }, + ]; + + for (const config of doNotDisplayConfigs) { + const { unmount } = await renderAccordion({ + isReadingEntityData: config.isReadingEntityData, + repoUrl: config.repoUrl, + }); + + const entityToggle = screen.queryByRole('button', { + name: matchers.toggles.entity, + }); + + expect(entityToggle).not.toBeInTheDocument(); + unmount(); + } + + // Verify that toggle appears only this one time + await renderAccordion({ + isReadingEntityData: true, + repoUrl: undefined, + }); + + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + + expect(entityToggle).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx new file mode 100644 index 00000000..34666194 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx @@ -0,0 +1,146 @@ +import React, { type ReactNode, Fragment, useState } from 'react'; +import { type Theme, makeStyles } from '@material-ui/core'; +import { VisuallyHidden } from '../VisuallyHidden'; +import { useWorkspacesCardContext } from './Root'; +import { Disclosure } from '../Disclosure/Disclosure'; +import { InlineCodeSnippet as Snippet } from '../InlineCodeSnippet/InlineCodeSnippet'; + +type AccordionItemInfo = Readonly<{ + id: string; + canDisplay: boolean; + headerText: ReactNode; + bodyText: ReactNode; +}>; + +type StyleKeys = 'root' | 'link' | 'innerPadding' | 'disclosure'; +type StyleInputs = Readonly<{ + hasData: boolean; +}>; + +const useStyles = makeStyles(theme => ({ + root: ({ hasData }) => ({ + paddingTop: theme.spacing(1), + marginLeft: `-${theme.spacing(2)}px`, + marginRight: `-${theme.spacing(2)}px`, + marginBottom: `-${theme.spacing(2)}px`, + borderTop: hasData ? 'none' : `1px solid ${theme.palette.divider}`, + maxHeight: '240px', + overflowX: 'hidden', + overflowY: 'auto', + }), + + innerPadding: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + paddingBottom: theme.spacing(2), + }, + + link: { + color: theme.palette.link, + '&:hover': { + textDecoration: 'underline', + }, + }, + + disclosure: { + '&:not(:first-child)': { + paddingTop: theme.spacing(1), + }, + }, +})); + +export type ReminderAccordionProps = Readonly<{ + canShowEntityReminder?: boolean; + canShowTemplateNameReminder?: boolean; +}>; + +export function ReminderAccordion({ + canShowEntityReminder = true, + canShowTemplateNameReminder = true, +}: ReminderAccordionProps) { + const [activeItemId, setActiveItemId] = useState(); + const { workspacesConfig, workspacesQuery } = useWorkspacesCardContext(); + const styles = useStyles({ hasData: workspacesQuery.data !== undefined }); + + const accordionData: readonly AccordionItemInfo[] = [ + { + id: 'entity', + canDisplay: + canShowEntityReminder && + workspacesConfig.isReadingEntityData && + !workspacesConfig.repoUrl, + headerText: 'Why am I not seeing any workspaces?', + bodyText: ( + <> + This component only displays all workspaces when the value of the{' '} + readEntityData prop is false. + See{' '} + + our documentation + (link opens in new tab) + {' '} + for more info. + + ), + }, + { + id: 'templateName', + canDisplay: canShowTemplateNameReminder && !workspacesConfig.creationUrl, + headerText: <>Why can't I make a new workspace?, + bodyText: ( + <> + This component cannot make a new workspace without a template name + value. Values can be provided via{' '} + defaultTemplateName in{' '} + CoderAppConfig or the{' '} + templateName property in a repo's{' '} + catalog-info.yaml file. See{' '} + + our documentation + (link opens in new tab) + {' '} + for more info. + + ), + }, + ]; + + const toggleAccordionGroup = (newItemId: string) => { + if (newItemId === activeItemId) { + setActiveItemId(undefined); + } else { + setActiveItemId(newItemId); + } + }; + + return ( +
    +
    + {accordionData.map(({ id, canDisplay, headerText, bodyText }) => ( + + {canDisplay && ( + toggleAccordionGroup(id)} + > + {bodyText} + + )} + + ))} +
    +
    + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 6829753a..9a2d118f 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -1,9 +1,6 @@ /** * @file Wires up all the core logic for passing values down to the * sub-components in the same directory. - * - * Does not need any tests – test functionality covered by integration tests in - * CoderWorkspacesCard */ import React, { type HTMLAttributes, @@ -22,7 +19,6 @@ import type { Workspace } from '../../typesConstants'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; import { Card } from '../Card'; import { CoderAuthWrapper } from '../CoderAuthWrapper'; -import { EntityDataReminder } from './EntityDataReminder'; export type WorkspacesQuery = UseQueryResult; @@ -47,7 +43,7 @@ export type WorkspacesCardProps = Readonly< } >; -export const Root = ({ +const InnerRoot = ({ children, className, queryFilter: outerFilter, @@ -56,7 +52,6 @@ export const Root = ({ readEntityData = false, ...delegatedProps }: WorkspacesCardProps) => { - const hookId = useId(); const [innerFilter, setInnerFilter] = useState(defaultQueryFilter); const activeFilter = outerFilter ?? innerFilter; @@ -66,11 +61,8 @@ export const Root = ({ coderQuery: activeFilter, }); + const hookId = useId(); const headerId = `${hookId}-header`; - const showEntityDataReminder = - readEntityData && - !workspacesConfig.repoUrl && - workspacesQuery.data !== undefined; return ( @@ -99,13 +91,22 @@ export const Root = ({ cases around keyboard input and button children that native
    elements automatically introduce */}
    {children}
    - {showEntityDataReminder && } ); }; +export function Root(props: WorkspacesCardProps) { + // Doing this to insulate the user from needing to worry about accidentally + // flipping the value of readEntityData between renders. If this value + // changes, it will cause the component to unmount and remount, but that + // should be painless/maybe invisible compared to having the component throw + // a full error and triggering an error boundary + const renderKey = String(props.readEntityData ?? false); + return ; +} + export function useWorkspacesCardContext(): WorkspacesCardContext { const contextValue = useContext(CardContext); if (contextValue === null) { diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx index ecb31bb7..a0894946 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { CardContext, WorkspacesCardContext } from './Root'; import { SearchBox } from './SearchBox'; -import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -28,18 +27,14 @@ async function renderSearchBox(input?: RenderInputs) { const { queryFilter = 'owner:me' } = input ?? {}; const onFilterChange = jest.fn(); - const mockContext: WorkspacesCardContext = { + const mockContext: Partial = { onFilterChange, queryFilter, - headerId: "Doesn't matter", - workspacesConfig: mockCoderWorkspacesConfig, - workspacesQuery: - null as unknown as WorkspacesCardContext['workspacesQuery'], }; const renderOutput = await renderInCoderEnvironment({ children: ( - + ), diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx index f2033a82..50bc1de1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx @@ -10,22 +10,22 @@ import { screen } from '@testing-library/react'; type RenderInputs = Readonly<{ workspacesQuery: Partial; renderListItem?: WorkspacesListProps['renderListItem']; + repoUrl?: string; }>; function renderWorkspacesList(inputs?: RenderInputs) { - const { renderListItem, workspacesQuery } = inputs ?? {}; - - const mockContext: WorkspacesCardContext = { - headerId: "Doesn't matter", - queryFilter: "Also doesn't matter", - onFilterChange: jest.fn(), - workspacesConfig: mockCoderWorkspacesConfig, + const { renderListItem, workspacesQuery, repoUrl } = inputs ?? {}; + const mockContext: Partial = { workspacesQuery: workspacesQuery as WorkspacesQuery, + workspacesConfig: { + ...mockCoderWorkspacesConfig, + repoUrl, + }, }; return renderInCoderEnvironment({ children: ( - + ), @@ -38,8 +38,8 @@ function renderWorkspacesList(inputs?: RenderInputs) { describe(`${WorkspacesList.name}`, () => { it('Allows the user to provide their own callback for iterating through each item', async () => { const workspaceNames = ['dog', 'cat', 'bird']; - await renderWorkspacesList({ + repoUrl: mockCoderWorkspacesConfig.repoUrl, workspacesQuery: { data: workspaceNames.map((name, index) => ({ ...mockWorkspaceWithMatch, @@ -63,4 +63,24 @@ describe(`${WorkspacesList.name}`, () => { expect(listItem).toBeInstanceOf(HTMLLIElement); } }); + + it('Displays the call-to-action link for making new workspaces when nothing is loading, but there is no data', async () => { + await renderWorkspacesList({ + repoUrl: mockCoderWorkspacesConfig.repoUrl, + workspacesQuery: { data: [] }, + }); + + const ctaLink = screen.getByRole('link', { name: /Create workspace/ }); + expect(ctaLink).toBeInTheDocument(); + }); + + it('Does NOT display the call-to-action link for making new workspaces when there is no workspace creation URL', async () => { + await renderWorkspacesList({ + repoUrl: undefined, + workspacesQuery: { data: [] }, + }); + + const ctaLink = screen.queryByRole('link', { name: /Create workspace/ }); + expect(ctaLink).not.toBeInTheDocument(); + }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index 03860201..1e47b08a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -97,12 +97,12 @@ export const WorkspacesList = ({ {workspacesQuery.data?.length === 0 && ( <> {emptyState ?? ( - + {repoUrl ? ( -
    + No workspaces found for repo {repoUrl} -
    + ) : ( <>No workspaces returned for your query )} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts index 55b94206..deff6410 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts @@ -7,3 +7,4 @@ export * from './SearchBox'; export * from './WorkspacesList'; export * from './WorkspacesListIcon'; export * from './WorkspacesListItem'; +export * from './ReminderAccordion'; diff --git a/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx new file mode 100644 index 00000000..09894e48 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type DisclosureProps, Disclosure } from './Disclosure'; + +type RenderInputs = Partial; + +function renderDisclosure(inputs?: RenderInputs) { + const { headerText, children, isExpanded, onExpansionToggle } = inputs ?? {}; + + return render( + + {children} + , + ); +} + +describe(`${Disclosure.name}`, () => { + it('Will toggle between showing/hiding the disclosure info when the user clicks it', async () => { + const headerText = 'Blah'; + const children = 'Blah blah blah blah'; + renderDisclosure({ headerText, children }); + + const user = userEvent.setup(); + const disclosureButton = screen.getByRole('button', { name: headerText }); + await user.click(disclosureButton); + + const disclosureInfo = await screen.findByText(children); + await user.click(disclosureButton); + expect(disclosureInfo).not.toBeInTheDocument(); + }); + + it('Can flip from an uncontrolled input to a controlled one if additional props are passed in', async () => { + const headerText = 'Blah'; + const children = 'Blah blah blah blah'; + const onExpansionToggle = jest.fn(); + + const { rerender } = renderDisclosure({ + onExpansionToggle, + headerText, + children, + isExpanded: true, + }); + + const user = userEvent.setup(); + const disclosureInfo = await screen.findByText(children); + const disclosureButton = screen.getByRole('button', { name: headerText }); + + await user.click(disclosureButton); + expect(onExpansionToggle).toHaveBeenCalled(); + + rerender( + + {children} + , + ); + + expect(disclosureInfo).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx new file mode 100644 index 00000000..c53eca54 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx @@ -0,0 +1,93 @@ +import React, { type HTMLAttributes, type ReactNode, useState } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + disclosureTriangle: { + display: 'inline-block', + textAlign: 'right', + width: theme.spacing(2.25), + fontSize: '0.7rem', + }, + + disclosureBody: { + margin: 0, + padding: `${theme.spacing(0.5)}px ${theme.spacing(3.5)}px 0 ${theme.spacing( + 4, + )}px`, + }, + + button: { + width: '100%', + textAlign: 'left', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(1), + border: 'none', + borderRadius: theme.shape.borderRadius, + fontSize: theme.typography.body2.fontSize, + cursor: 'pointer', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + + '&:not(:first-child)': { + paddingTop: theme.spacing(6), + }, + }, +})); + +export type DisclosureProps = Readonly< + HTMLAttributes & { + isExpanded?: boolean; + onExpansionToggle?: () => void; + headerText: ReactNode; + } +>; + +export const Disclosure = ({ + isExpanded, + onExpansionToggle, + headerText, + children, + ...delegatedProps +}: DisclosureProps) => { + const hookId = useId(); + const styles = useStyles(); + const [internalIsExpanded, setInternalIsExpanded] = useState( + isExpanded ?? false, + ); + + const activeIsExpanded = isExpanded ?? internalIsExpanded; + const disclosureBodyId = `${hookId}-disclosure-body`; + + // Might be worth revisiting the markup here to try implementing this + // functionality with and elements. Would likely clean up + // the component code a bit but might reduce control over screen reader output + return ( +
    + + + {activeIsExpanded && ( +

    + {children} +

    + )} +
    + ); +}; diff --git a/plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx b/plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx new file mode 100644 index 00000000..7743bdc8 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx @@ -0,0 +1,32 @@ +import React, { HTMLAttributes } from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + root: { + fontSize: theme.typography.body2.fontSize, + color: theme.palette.text.primary, + borderRadius: theme.spacing(0.5), + padding: `${theme.spacing(0.2)}px ${theme.spacing(1)}px`, + backgroundColor: () => { + const isLightTheme = theme.palette.type === 'light'; + return isLightTheme + ? 'hsl(0deg,0%,93%)' + : theme.palette.background.default; + }, + }, +})); + +type Props = Readonly< + Omit, 'children'> & { + children: string; + } +>; + +export function InlineCodeSnippet({ children, ...delegatedProps }: Props) { + const styles = useStyles(); + return ( + + {children} + + ); +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts index 8e189225..bfd079b5 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts @@ -111,6 +111,7 @@ describe(`${useCoderWorkspacesConfig.name}`, () => { ); expect(result.current).toEqual({ + isReadingEntityData: true, mode: mockYamlConfig.mode, repoUrl: cleanedRepoUrl, creationUrl: mockCoderWorkspacesConfig.creationUrl, diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts index 999a60b7..67bbb556 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts @@ -23,19 +23,22 @@ import { useCoderAppConfig, } from '../components/CoderProvider'; +const workspaceCreationModeSchema = optional( + union( + [literal('manual'), literal('auto')], + "If defined, createMode must be 'manual' or 'auto'", + ), +); + +export type WorkspaceCreationMode = Output; + // Very loose parsing requirements to make interfacing with various kinds of // YAML files as easy as possible const yamlConfigSchema = union([ undefined_(), object({ templateName: optional(string()), - mode: optional( - union( - [literal('manual'), literal('auto')], - "If defined, createMode must be 'manual' or 'auto'", - ), - ), - + mode: workspaceCreationModeSchema, params: optional( record( string(), @@ -49,6 +52,11 @@ const yamlConfigSchema = union([ }), ]); +/** + * The set of properties that the Coder plugin is configured to parse from a + * repo's catalog-info.yaml file. The entire value will be undefined if a repo + * does not have the file + */ export type YamlConfig = Output; /** @@ -56,11 +64,12 @@ export type YamlConfig = Output; * sourced from CoderAppConfig and any entity data. */ export type CoderWorkspacesConfig = - // Was originally defined in terms of fancy mapped types; ended up being a bad - // idea, because it increased coupling in a bad way + // Was originally defined in terms of fancy mapped types based on YamlConfig; + // ended up being a bad idea, because it increased coupling in a bad way Readonly<{ - creationUrl: string; - templateName: string; + isReadingEntityData: boolean; + creationUrl?: string; + templateName?: string; repoUrlParamKeys: readonly string[]; mode: 'manual' | 'auto'; params: Record; @@ -71,17 +80,19 @@ export type CoderWorkspacesConfig = export function compileCoderConfig( appConfig: CoderAppConfig, - rawYamlConfig: unknown, + rawYamlConfig: unknown, // Function parses this into more specific type repoUrl: string | undefined, ): CoderWorkspacesConfig { const { workspaces, deployment } = appConfig; const yamlConfig = parse(yamlConfigSchema, rawYamlConfig); - const mode = yamlConfig?.mode ?? workspaces.mode ?? 'manual'; + const mode = yamlConfig?.mode ?? workspaces.defaultMode ?? 'manual'; + const templateName = + yamlConfig?.templateName ?? workspaces.defaultTemplateName; const urlParams = new URLSearchParams({ mode }); const compiledParams: Record = {}; - // Can't replace this with destructuring, because that is all-or-nothing; + // Can't replace section with destructuring, because that's all-or-nothing; // there's no easy way to granularly check each property without a loop const paramsPrecedence = [workspaces.params, yamlConfig?.params ?? {}]; for (const params of paramsPrecedence) { @@ -112,21 +123,22 @@ export function compileCoderConfig( } } - const safeTemplate = encodeURIComponent( - yamlConfig?.templateName ?? workspaces.templateName, - ); - - const creationUrl = `${ - deployment.accessUrl - }/templates/${safeTemplate}/workspace?${urlParams.toString()}`; + let creationUrl: string | undefined = undefined; + if (templateName) { + const safeTemplate = encodeURIComponent(templateName); + creationUrl = `${ + deployment.accessUrl + }/templates/${safeTemplate}/workspace?${urlParams.toString()}`; + } return { + mode, creationUrl, + templateName, repoUrl: cleanedRepoUrl, + isReadingEntityData: yamlConfig !== undefined, repoUrlParamKeys: workspaces.repoUrlParamKeys, params: compiledParams, - templateName: yamlConfig?.templateName ?? workspaces.templateName, - mode: yamlConfig?.mode ?? workspaces.mode ?? 'manual', }; } diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 85ae7178..7de9929e 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -149,6 +149,18 @@ export const CoderWorkspacesCardWorkspacesListItem = coderPlugin.provide( }), ); +export const CoderWorkspacesReminderAccordion = coderPlugin.provide( + createComponentExtension({ + name: 'CoderWorkspacesCard.ReminderAccordion', + component: { + lazy: () => + import('./components/CoderWorkspacesCard').then( + m => m.ReminderAccordion, + ), + }, + }), +); + /** * All custom hooks exposed by the plugin. */ diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 10b8723e..049050cc 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -89,8 +89,8 @@ export const mockAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', @@ -99,7 +99,7 @@ export const mockAppConfig = { }, } as const satisfies CoderAppConfig; -export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { +export const mockCoderWorkspacesConfig = (() => { const urlParams = new URLSearchParams({ mode: mockYamlConfig.mode, 'param.repo': mockAppConfig.workspaces.params.repo, @@ -110,6 +110,7 @@ export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { return { mode: 'auto', + isReadingEntityData: true, templateName: mockYamlConfig.templateName, repoUrlParamKeys: ['custom_repo', 'repo_url'], repoUrl: cleanedRepoUrl, @@ -124,7 +125,7 @@ export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { custom_repo: cleanedRepoUrl, repo_url: cleanedRepoUrl, }, - }; + } as const satisfies CoderWorkspacesConfig; })(); const authedState = { From 51e52794c49af046c3b23354154f8ec032ce6a5c Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 4 Apr 2024 15:54:10 -0400 Subject: [PATCH 15/33] docs: update documentation to reflect current releases (and add disclaimers) (#106) --- plugins/backstage-plugin-coder/docs/README.md | 2 ++ plugins/backstage-plugin-devcontainers-backend/README.md | 2 -- plugins/backstage-plugin-devcontainers-backend/docs/README.md | 2 ++ plugins/backstage-plugin-devcontainers-backend/docs/classes.md | 2 -- plugins/backstage-plugin-devcontainers-react/docs/README.md | 2 ++ 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/README.md b/plugins/backstage-plugin-coder/docs/README.md index 7ca73a4e..1aac4a05 100644 --- a/plugins/backstage-plugin-coder/docs/README.md +++ b/plugins/backstage-plugin-coder/docs/README.md @@ -2,6 +2,8 @@ For users who need more information about how to extend and modify the Coder plugin. For general setup, please see our main [README](../README.md). +All documentation reflects version `v0.2.0` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. + ## Documentation directory - [Components](./components.md) diff --git a/plugins/backstage-plugin-devcontainers-backend/README.md b/plugins/backstage-plugin-devcontainers-backend/README.md index ac423323..e404c42a 100644 --- a/plugins/backstage-plugin-devcontainers-backend/README.md +++ b/plugins/backstage-plugin-devcontainers-backend/README.md @@ -76,7 +76,6 @@ _Note: While this plugin has been developed and published by Coder, no Coder ins DevcontainersProcessor.fromConfig(env.config, { tagName: 'example', // Defaults to devcontainers logger: env.logger, - eraseTags: false, }), ); @@ -115,7 +114,6 @@ export default async function createPlugin( builder.addProcessor( DevcontainersProcessor.fromConfig(env.config, { logger: env.logger, - eraseTags: false, }), ); diff --git a/plugins/backstage-plugin-devcontainers-backend/docs/README.md b/plugins/backstage-plugin-devcontainers-backend/docs/README.md index 30fa1c61..a85226e2 100644 --- a/plugins/backstage-plugin-devcontainers-backend/docs/README.md +++ b/plugins/backstage-plugin-devcontainers-backend/docs/README.md @@ -2,6 +2,8 @@ For users who need more information about how to extend and modify the Dev Containers plugin. For general setup, please see our main [README](../README.md). +All documentation reflects version `v0.1.0` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. + ## Documentation directory - [Classes](./classes.md) diff --git a/plugins/backstage-plugin-devcontainers-backend/docs/classes.md b/plugins/backstage-plugin-devcontainers-backend/docs/classes.md index fa1573f5..22051715 100644 --- a/plugins/backstage-plugin-devcontainers-backend/docs/classes.md +++ b/plugins/backstage-plugin-devcontainers-backend/docs/classes.md @@ -15,7 +15,6 @@ This class provides a custom [catalog processor](https://backstage.io/docs/featu ```tsx type ProcessorOptions = Readonly<{ tagName: string; - eraseTags: boolean; logger: Logger; }>; @@ -62,7 +61,6 @@ export default async function createPlugin( builder.addProcessor( DevcontainersProcessor.fromConfig(env.config, { logger: env.logger, - eraseTags: false, }), ); diff --git a/plugins/backstage-plugin-devcontainers-react/docs/README.md b/plugins/backstage-plugin-devcontainers-react/docs/README.md index 9c09102d..1080b2dc 100644 --- a/plugins/backstage-plugin-devcontainers-react/docs/README.md +++ b/plugins/backstage-plugin-devcontainers-react/docs/README.md @@ -2,6 +2,8 @@ For users who need more information about how to extend and modify the Dev Containers plugin. For general setup, please see our main [README](../README.md). +All documentation reflects version `v0.1.0` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. + ## Documentation directory - [Components](./components.md) From e013e358feb94a6c4ba8cd71aa2c7a73f7e9f2f4 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 11 Apr 2024 10:31:07 -0400 Subject: [PATCH 16/33] updating peer dependencies to match the scaffolder (#113) --- plugins/backstage-plugin-coder/package.json | 2 +- plugins/backstage-plugin-devcontainers-react/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index a618c639..e48c8f21 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -44,7 +44,7 @@ "valibot": "^0.28.1" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { "@backstage/cli": "^0.25.1", diff --git a/plugins/backstage-plugin-devcontainers-react/package.json b/plugins/backstage-plugin-devcontainers-react/package.json index 5edb702e..9f370e32 100644 --- a/plugins/backstage-plugin-devcontainers-react/package.json +++ b/plugins/backstage-plugin-devcontainers-react/package.json @@ -34,7 +34,7 @@ "@material-ui/lab": "4.0.0-alpha.61" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { "@backstage/cli": "^0.25.1", From ea46efcfaf2838b064e9c86b90e3118c62f6f0ea Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Mon, 15 Apr 2024 17:16:58 -0400 Subject: [PATCH 17/33] fix: update confusing example in Coder plugin README (#115) * fix: update confusing example in Coder plugin README * fix: remove accidental whitespace --- plugins/backstage-plugin-coder/README.md | 27 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/README.md b/plugins/backstage-plugin-coder/README.md index eb53cb29..657521e2 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -47,7 +47,8 @@ the Dev Container. 3. Add the `CoderProvider` to the application: ```tsx - // In packages/app/src/App.tsx + // packages/app/src/App.tsx + import { type CoderAppConfig, CoderProvider, @@ -94,14 +95,26 @@ the Dev Container. 4. Add the `CoderWorkspacesCard` card to the entity page in your app: ```tsx - // In packages/app/src/components/catalog/EntityPage.tsx - import { CoderWorkspacesCard } from '@coder/backstage-plugin-coder'; + // packages/app/src/components/catalog/EntityPage.tsx - // ... + import { CoderWorkspacesCard } from '@coder/backstage-plugin-coder'; - - - ; + // We recommend placing the component inside of overviewContent + const overviewContent = ( + + {entityWarningContent} + + + + + {/* Coder component should go inside Grid to help it work with MUI layouts */} + + + + + {/* Other elements for overviewContent go here */} + + ); ``` ### `app-config.yaml` files From 04a1c151f43ebf22667ac9ecc4da75f746f2fbfa Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 19 Apr 2024 09:24:47 -0400 Subject: [PATCH 18/33] fix(Coder plugin): Update integration with Backstage's identity API (#118) * fix: update UI code to forward bearer tokens properly * refactor: consolidate init setup logic * fix: update error catching logic * fix: add new mock to get current tests passing * fix: add mock bearer token * chore: add test middleware to verify bearer token behavior * refactor: update variable names for clarity --- plugins/backstage-plugin-coder/src/api.ts | 44 ++++++++++--- .../CoderProvider/CoderAuthProvider.tsx | 4 +- .../CoderProvider/CoderProvider.test.tsx | 8 ++- .../src/hooks/useCoderWorkspacesQuery.ts | 12 +++- .../src/testHelpers/mockBackstageData.ts | 29 +++++++++ .../src/testHelpers/server.ts | 65 +++++++++++++++++-- .../src/testHelpers/setup.tsx | 11 +++- 7 files changed, 154 insertions(+), 19 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api.ts index 02dadbe4..d11248eb 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api.ts @@ -9,6 +9,7 @@ import { WorkspaceAgentStatus, } from './typesConstants'; import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider'; +import { IdentityApi } from '@backstage/core-plugin-api'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; @@ -19,9 +20,31 @@ export const ASSETS_ROUTE_PREFIX = PROXY_ROUTE_PREFIX; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; export const REQUEST_TIMEOUT_MS = 20_000; -function getCoderApiRequestInit(authToken: string): RequestInit { +async function getCoderApiRequestInit( + authToken: string, + identity: IdentityApi, +): Promise { + const headers: HeadersInit = { + [CODER_AUTH_HEADER_KEY]: authToken, + }; + + try { + const credentials = await identity.getCredentials(); + if (credentials.token) { + headers.Authorization = `Bearer ${credentials.token}`; + } + } catch (err) { + if (err instanceof Error) { + throw err; + } + + throw new Error( + "Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API", + ); + } + return { - headers: { [CODER_AUTH_HEADER_KEY]: authToken }, + headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }; } @@ -53,6 +76,7 @@ export class BackstageHttpError extends Error { type FetchInputs = Readonly<{ auth: CoderAuth; baseUrl: string; + identity: IdentityApi; }>; type WorkspacesFetchInputs = Readonly< @@ -64,7 +88,7 @@ type WorkspacesFetchInputs = Readonly< async function getWorkspaces( fetchInputs: WorkspacesFetchInputs, ): Promise { - const { baseUrl, coderQuery, auth } = fetchInputs; + const { baseUrl, coderQuery, auth, identity } = fetchInputs; assertValidCoderAuth(auth); const urlParams = new URLSearchParams({ @@ -72,9 +96,10 @@ async function getWorkspaces( limit: '0', }); + const requestInit = await getCoderApiRequestInit(auth.token, identity); const response = await fetch( `${baseUrl}${API_ROUTE_PREFIX}/workspaces?${urlParams.toString()}`, - getCoderApiRequestInit(auth.token), + requestInit, ); if (!response.ok) { @@ -116,12 +141,13 @@ type BuildParamsFetchInputs = Readonly< >; async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { baseUrl, auth, workspaceBuildId } = inputs; + const { baseUrl, auth, workspaceBuildId, identity } = inputs; assertValidCoderAuth(auth); + const requestInit = await getCoderApiRequestInit(auth.token, identity); const res = await fetch( `${baseUrl}${API_ROUTE_PREFIX}/workspacebuilds/${workspaceBuildId}/parameters`, - getCoderApiRequestInit(auth.token), + requestInit, ); if (!res.ok) { @@ -256,16 +282,18 @@ export function workspacesByRepo( type AuthValidationInputs = Readonly<{ baseUrl: string; authToken: string; + identity: IdentityApi; }>; async function isAuthValid(inputs: AuthValidationInputs): Promise { - const { baseUrl, authToken } = inputs; + const { baseUrl, authToken, identity } = inputs; // In this case, the request doesn't actually matter. Just need to make any // kind of dummy request to validate the auth + const requestInit = await getCoderApiRequestInit(authToken, identity); const response = await fetch( `${baseUrl}${API_ROUTE_PREFIX}/users/me`, - getCoderApiRequestInit(authToken), + requestInit, ); if (response.status >= 400 && response.status !== 401) { diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 3192198e..8dd9a741 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -19,6 +19,7 @@ import { authValidation, } from '../../api'; import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -98,6 +99,7 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { + const identity = useApi(identityApiRef); const { baseUrl } = useBackstageEndpoints(); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); @@ -108,7 +110,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const [readonlyInitialAuthToken] = useState(authToken); const authValidityQuery = useQuery({ - ...authValidation({ baseUrl, authToken }), + ...authValidation({ baseUrl, authToken, identity }), refetchOnWindowFocus: query => query.state.data !== false, }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 2a240a75..41e75bee 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -3,7 +3,11 @@ import { renderHook } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; -import { configApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { + configApiRef, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; import { CoderProvider } from './CoderProvider'; import { useCoderAppConfig } from './CoderAppConfigProvider'; @@ -12,6 +16,7 @@ import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, getMockErrorApi, + getMockIdentityApi, mockAppConfig, mockCoderAuthToken, } from '../../testHelpers/mockBackstageData'; @@ -87,6 +92,7 @@ describe(`${CoderProvider.name}`, () => { diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 22556fda..3517ad2b 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -4,6 +4,7 @@ import { workspaces, workspacesByRepo } from '../api'; import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; import { useBackstageEndpoints } from './useBackstageEndpoints'; import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; type QueryInput = Readonly<{ coderQuery: string; @@ -15,12 +16,19 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); + const identity = useApi(identityApiRef); const { baseUrl } = useBackstageEndpoints(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ coderQuery, auth, baseUrl, workspacesConfig }) - : workspaces({ coderQuery, auth, baseUrl }); + ? workspacesByRepo({ + coderQuery, + identity, + auth, + baseUrl, + workspacesConfig, + }) + : workspaces({ coderQuery, identity, auth, baseUrl }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 049050cc..2e0fa6fe 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -17,6 +17,7 @@ import { import { ScmIntegrationsApi } from '@backstage/integration-react'; import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; +import { IdentityApi } from '@backstage/core-plugin-api'; /** * This is the key that Backstage checks from the entity data to determine the @@ -57,6 +58,7 @@ export const mockBackstageProxyEndpoint = `${mockBackstageUrlRoot}${API_ROUTE_PR export const mockBackstageAssetsEndpoint = `${mockBackstageUrlRoot}${ASSETS_ROUTE_PREFIX}`; +export const mockBearerToken = 'This-is-an-opaque-value-by-design'; export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X'; export const mockYamlConfig = { @@ -207,6 +209,33 @@ export function getMockErrorApi() { return errorApi; } +export function getMockIdentityApi(): IdentityApi { + return { + signOut: async () => { + return void 'Not going to implement this'; + }, + getProfileInfo: async () => { + return { + displayName: 'Dobah', + email: 'i-love-my-dog-dobah@dog.ceo', + picture: undefined, + }; + }, + getBackstageIdentity: async () => { + return { + type: 'user', + userEntityRef: 'User:default/Dobah', + ownershipEntityRefs: [], + }; + }, + getCredentials: async () => { + return { + token: mockBearerToken, + }; + }, + }; +} + /** * Exposes a mock ScmIntegrationRegistry to be used with scmIntegrationsApiRef * for mocking out code that relies on source code data. diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 5602241d..99db7c1b 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -1,5 +1,12 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { RestHandler, rest } from 'msw'; +import { + type DefaultBodyType, + type ResponseResolver, + type RestContext, + type RestHandler, + type RestRequest, + rest, +} from 'msw'; import { setupServer } from 'msw/node'; /* eslint-enable @backstage/no-undeclared-imports */ @@ -8,14 +15,60 @@ import { mockWorkspaceBuildParameters, } from './mockCoderAppData'; import { + mockBearerToken, mockCoderAuthToken, mockBackstageProxyEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api'; -const handlers: readonly RestHandler[] = [ - rest.get(`${root}/workspaces`, (req, res, ctx) => { +type RestResolver = ResponseResolver< + RestRequest, + RestContext, + TBody +>; + +export type RestResolverMiddleware = ( + resolver: RestResolver, +) => RestResolver; + +const defaultMiddleware = [ + function validateBearerToken(handler) { + return (req, res, ctx) => { + const tokenRe = /^Bearer (.+)$/; + const authHeader = req.headers.get('Authorization') ?? ''; + const [, bearerToken] = tokenRe.exec(authHeader) ?? []; + + if (bearerToken === mockBearerToken) { + return handler(req, res, ctx); + } + + return res(ctx.status(401)); + }; + }, +] as const satisfies readonly RestResolverMiddleware[]; + +export function wrapInDefaultMiddleware( + resolver: RestResolver, +): RestResolver { + return defaultMiddleware.reduceRight((currentResolver, middleware) => { + const recastMiddleware = + middleware as unknown as RestResolverMiddleware; + + return recastMiddleware(currentResolver); + }, resolver); +} + +function wrappedGet( + path: string, + resolver: RestResolver, +): RestHandler { + const wrapped = wrapInDefaultMiddleware(resolver); + return rest.get(path, wrapped); +} + +const mainTestHandlers: readonly RestHandler[] = [ + wrappedGet(`${root}/workspaces`, (req, res, ctx) => { const queryText = String(req.url.searchParams.get('q')); let returnedWorkspaces: Workspace[]; @@ -36,7 +89,7 @@ const handlers: readonly RestHandler[] = [ ); }), - rest.get( + wrappedGet( `${root}/workspacebuilds/:workspaceBuildId/parameters`, (req, res, ctx) => { const buildId = String(req.params.workspaceBuildId); @@ -51,7 +104,7 @@ const handlers: readonly RestHandler[] = [ ), // This is the dummy request used to verify a user's auth status - rest.get(`${root}/users/me`, (req, res, ctx) => { + wrappedGet(`${root}/users/me`, (req, res, ctx) => { const token = req.headers.get(CODER_AUTH_HEADER_KEY); if (token === mockCoderAuthToken) { return res(ctx.status(200)); @@ -61,4 +114,4 @@ const handlers: readonly RestHandler[] = [ }), ]; -export const server = setupServer(...handlers); +export const server = setupServer(...mainTestHandlers); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 92a23594..70afba5b 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -12,7 +12,11 @@ import { import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { configApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { + configApiRef, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { type CoderAuth, @@ -30,6 +34,7 @@ import { getMockConfigApi, mockAuthStates, BackstageEntity, + getMockIdentityApi, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; @@ -159,6 +164,7 @@ export const renderHookAsCoderEntity = async < const mockErrorApi = getMockErrorApi(); const mockSourceControl = getMockSourceControl(); const mockConfigApi = getMockConfigApi(); + const mockIdentityApi = getMockIdentityApi(); const mockQueryClient = getMockQueryClient(); const renderHookValue = renderHook(hook, { @@ -168,6 +174,7 @@ export const renderHookAsCoderEntity = async < Date: Wed, 24 Apr 2024 15:13:41 -0400 Subject: [PATCH 19/33] chore: add StateSnapshotManager class (#119) --- .../src/typesConstants.ts | 8 + .../src/utils/StateSnapshotManager.test.ts | 204 ++++++++++++++++++ .../src/utils/StateSnapshotManager.ts | 166 ++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts create mode 100644 plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index 0b5151ca..d4b613c7 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -9,6 +9,14 @@ import { optional, } from 'valibot'; +export type ReadonlyJsonValue = + | string + | number + | boolean + | null + | readonly ReadonlyJsonValue[] + | Readonly<{ [key: string]: ReadonlyJsonValue }>; + export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; export const workspaceAgentStatusSchema = union([ diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts new file mode 100644 index 00000000..42f92312 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts @@ -0,0 +1,204 @@ +import type { ReadonlyJsonValue } from '../typesConstants'; +import { + StateSnapshotManager, + defaultDidSnapshotsChange, +} from './StateSnapshotManager'; + +describe(`${defaultDidSnapshotsChange.name}`, () => { + type SampleInput = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + it('Will detect when two JSON primitives are the same', () => { + const samples = [ + { snapshotA: true, snapshotB: true }, + { snapshotA: 'cat', snapshotB: 'cat' }, + { snapshotA: 2, snapshotB: 2 }, + { snapshotA: null, snapshotB: null }, + ] as const satisfies readonly SampleInput[]; + + for (const { snapshotA, snapshotB } of samples) { + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + } + }); + + it('Will detect when two JSON primitives are different', () => { + const samples = [ + { snapshotA: true, snapshotB: false }, + { snapshotA: 'cat', snapshotB: 'dog' }, + { snapshotA: 2, snapshotB: 789 }, + { snapshotA: null, snapshotB: 'blah' }, + ] as const satisfies readonly SampleInput[]; + + for (const { snapshotA, snapshotB } of samples) { + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(true); + } + }); + + it('Will detect when a value flips from a primitive to an object (or vice versa)', () => { + expect(defaultDidSnapshotsChange(null, {})).toBe(true); + expect(defaultDidSnapshotsChange({}, null)).toBe(true); + }); + + it('Will reject numbers that changed by a very small floating-point epsilon', () => { + expect(defaultDidSnapshotsChange(3, 3 / 1.00000001)).toBe(false); + }); + + it('Will check array values one level deep', () => { + const snapshotA = [1, 2, 3]; + + const snapshotB = [...snapshotA]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + + const snapshotC = [...snapshotA, 4]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true); + + const snapshotD = [...snapshotA, {}]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true); + }); + + it('Will check object values one level deep', () => { + const snapshotA = { cat: true, dog: true }; + + const snapshotB = { ...snapshotA, dog: true }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + + const snapshotC = { ...snapshotA, bird: true }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true); + + const snapshotD = { ...snapshotA, value: {} }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true); + }); +}); + +describe(`${StateSnapshotManager.name}`, () => { + it('Lets external systems subscribe and unsubscribe to internal snapshot changes', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + const samples = [ + { snapshotA: false, snapshotB: true }, + { snapshotA: 0, snapshotB: 1 }, + { snapshotA: 'cat', snapshotB: 'dog' }, + { snapshotA: null, snapshotB: 'neat' }, + { snapshotA: {}, snapshotB: { different: true } }, + { snapshotA: [], snapshotB: ['I have a value now!'] }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: defaultDidSnapshotsChange, + }); + + const unsubscribe = manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB); + + unsubscribe(); + manager.updateSnapshot(snapshotA); + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + } + }); + + it('Lets user define a custom comparison algorithm during instantiation', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + compare: (A: ReadonlyJsonValue, B: ReadonlyJsonValue) => boolean; + }>; + + const exampleDeeplyNestedJson: ReadonlyJsonValue = { + value1: { + value2: { + value3: 'neat', + }, + }, + + value4: { + value5: [{ valueX: true }, { valueY: false }], + }, + }; + + const samples = [ + { + snapshotA: exampleDeeplyNestedJson, + snapshotB: { + ...exampleDeeplyNestedJson, + value4: { + value5: [{ valueX: false }, { valueY: false }], + }, + }, + compare: (A, B) => JSON.stringify(A) !== JSON.stringify(B), + }, + { + snapshotA: { tag: 'snapshot-993', value: 1 }, + snapshotB: { tag: 'snapshot-2004', value: 1 }, + compare: (A, B) => { + const recastA = A as Record; + const recastB = B as Record; + return recastA.tag !== recastB.tag; + }, + }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB, compare } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: compare, + }); + + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB); + } + }); + + it('Rejects new snapshots that are equivalent to old ones, and does NOT notify subscribers', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + const samples = [ + { snapshotA: true, snapshotB: true }, + { snapshotA: 'kitty', snapshotB: 'kitty' }, + { snapshotA: null, snapshotB: null }, + { snapshotA: [], snapshotB: [] }, + { snapshotA: {}, snapshotB: {} }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: defaultDidSnapshotsChange, + }); + + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).not.toHaveBeenCalled(); + } + }); + + it("Uses the default comparison algorithm if one isn't specified at instantiation", () => { + const snapshotA = { value: 'blah' }; + const snapshotB = { value: 'blah' }; + + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + }); + + const subscriptionCallback = jest.fn(); + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + + expect(subscriptionCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts new file mode 100644 index 00000000..a109909d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts @@ -0,0 +1,166 @@ +/** + * @file A helper class that simplifies the process of connecting mutable class + * values (such as the majority of values from API factories) with React's + * useSyncExternalStore hook. + * + * This should not be used directly from within React, but should instead be + * composed into other classes (such as API factories). Those classes can then + * be brought into React. + * + * As long as you can figure out how to turn the mutable values in some other + * class into an immutable snapshot, all you have to do is pass the new snapshot + * into this class. It will then take care of notifying subscriptions, while + * reconciling old/new snapshots to minimize needless re-renders. + */ +import type { ReadonlyJsonValue } from '../typesConstants'; + +type SubscriptionCallback = ( + snapshot: TSnapshot, +) => void; + +type DidSnapshotsChange = ( + oldSnapshot: TSnapshot, + newSnapshot: TSnapshot, +) => boolean; + +type SnapshotManagerOptions = Readonly<{ + initialSnapshot: TSnapshot; + + /** + * Lets you define a custom comparison strategy for detecting whether a + * snapshot has really changed in a way that should be reflected in the UI. + */ + didSnapshotsChange?: DidSnapshotsChange; +}>; + +interface SnapshotManagerApi { + subscribe: (callback: SubscriptionCallback) => () => void; + unsubscribe: (callback: SubscriptionCallback) => void; + getSnapshot: () => TSnapshot; + updateSnapshot: (newSnapshot: TSnapshot) => void; +} + +function areSameByReference(v1: unknown, v2: unknown) { + // Comparison looks wonky, but Object.is handles more edge cases than === + // for these kinds of comparisons, but it itself has an edge case + // with -0 and +0. Still need === to handle that comparison + return Object.is(v1, v2) || (v1 === 0 && v2 === 0); +} + +/** + * Favors shallow-ish comparisons (will check one level deep for objects and + * arrays, but no more) + */ +export function defaultDidSnapshotsChange( + oldSnapshot: TSnapshot, + newSnapshot: TSnapshot, +): boolean { + if (areSameByReference(oldSnapshot, newSnapshot)) { + return false; + } + + const oldIsPrimitive = + typeof oldSnapshot !== 'object' || oldSnapshot === null; + const newIsPrimitive = + typeof newSnapshot !== 'object' || newSnapshot === null; + + if (oldIsPrimitive && newIsPrimitive) { + const numbersAreWithinTolerance = + typeof oldSnapshot === 'number' && + typeof newSnapshot === 'number' && + Math.abs(oldSnapshot - newSnapshot) < 0.00005; + + if (numbersAreWithinTolerance) { + return false; + } + + return oldSnapshot !== newSnapshot; + } + + const changedFromObjectToPrimitive = !oldIsPrimitive && newIsPrimitive; + const changedFromPrimitiveToObject = oldIsPrimitive && !newIsPrimitive; + + if (changedFromObjectToPrimitive || changedFromPrimitiveToObject) { + return true; + } + + if (Array.isArray(oldSnapshot) && Array.isArray(newSnapshot)) { + const sameByShallowComparison = + oldSnapshot.length === newSnapshot.length && + oldSnapshot.every((element, index) => + areSameByReference(element, newSnapshot[index]), + ); + + return !sameByShallowComparison; + } + + const oldInnerValues: unknown[] = Object.values(oldSnapshot as Object); + const newInnerValues: unknown[] = Object.values(newSnapshot as Object); + + if (oldInnerValues.length !== newInnerValues.length) { + return true; + } + + for (const [index, value] of oldInnerValues.entries()) { + if (value !== newInnerValues[index]) { + return true; + } + } + + return false; +} + +/** + * @todo Might eventually make sense to give the class the ability to merge + * snapshots more surgically and maximize structural sharing (which should be + * safe since the snapshots are immutable). But we can worry about that when it + * actually becomes a performance issue + */ +export class StateSnapshotManager< + TSnapshot extends ReadonlyJsonValue = ReadonlyJsonValue, +> implements SnapshotManagerApi +{ + private subscriptions: Set>; + private didSnapshotsChange: DidSnapshotsChange; + private activeSnapshot: TSnapshot; + + constructor(options: SnapshotManagerOptions) { + const { initialSnapshot, didSnapshotsChange } = options; + + this.subscriptions = new Set(); + this.activeSnapshot = initialSnapshot; + this.didSnapshotsChange = didSnapshotsChange ?? defaultDidSnapshotsChange; + } + + private notifySubscriptions(): void { + const snapshotBinding = this.activeSnapshot; + this.subscriptions.forEach(cb => cb(snapshotBinding)); + } + + unsubscribe = (callback: SubscriptionCallback): void => { + this.subscriptions.delete(callback); + }; + + subscribe = (callback: SubscriptionCallback): (() => void) => { + this.subscriptions.add(callback); + return () => this.unsubscribe(callback); + }; + + getSnapshot = (): TSnapshot => { + return this.activeSnapshot; + }; + + updateSnapshot = (newSnapshot: TSnapshot): void => { + const snapshotsChanged = this.didSnapshotsChange( + this.activeSnapshot, + newSnapshot, + ); + + if (!snapshotsChanged) { + return; + } + + this.activeSnapshot = newSnapshot; + this.notifySubscriptions(); + }; +} From dd2dc38c78303918a9f44ed716654b5a4ad36362 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 30 Apr 2024 12:09:32 -0400 Subject: [PATCH 20/33] refactor: reorganize API logic and create class/hook for simplifying proxy logic (#124) * wip: commit progress on UrlSync class/hook * refactor: consolidate emoji-testing logic * docs: update comments for clarity * refactor: rename helpers to renderHelpers * wip: finish initial implementation of UrlSync * chore: finish tests for UrlSync class * chore: add mock DiscoveryApi helper * chore: finish tests for useUrlSync * refactor: consolidate mock URL logic for useUrlSync * fix: update test helper to use API list * fix: remove unneeded imports * fix: get tests for all current code passing * fix: remove typo * fix: update useUrlSync to expose underlying api * refactor: increase data hiding for hook * fix: make useUrlSync tests less dependent on implementation details * refactor: remove reliance on baseUrl argument for fetch calls * refactor: split Backstage error type into separate file * refactor: clean up imports for api file * refactor: split main query options into separate file * consolidate how mock endpoints are defined * fix: remove base URL from auth calls * refactor: consolidate almost all auth logic into CoderAuthProvider * move api file into api directory * fix: revert prop that was changed for debugging * fix: revert prop definition * refactor: extract token-checking logic into middleware for server * refactor: move shared auth key to queryOptions file * docs: add reminder about arrow functions * fix: remove configApi from embedded class properties * fix: update query logic to remove any whitespace --- plugins/backstage-plugin-coder/package.json | 1 + .../src/api/UrlSync.test.ts | 90 ++++++++++ .../backstage-plugin-coder/src/api/UrlSync.ts | 157 +++++++++++++++++ .../src/{ => api}/api.ts | 159 ++++-------------- .../backstage-plugin-coder/src/api/errors.ts | 27 +++ .../src/api/queryOptions.ts | 90 ++++++++++ .../CoderProvider/CoderAuthProvider.tsx | 41 +++-- .../CoderProvider/CoderProvider.test.tsx | 23 ++- .../CoderProvider/CoderProvider.tsx | 4 +- .../WorkspacesListIcon.tsx | 9 +- .../WorkspacesListItem.tsx | 2 +- .../src/hooks/useBackstageEndpoints.test.ts | 26 --- .../src/hooks/useBackstageEndpoints.ts | 19 --- .../src/hooks/useCoderWorkspacesQuery.ts | 14 +- .../src/hooks/useUrlSync.test.tsx | 91 ++++++++++ .../src/hooks/useUrlSync.ts | 52 ++++++ plugins/backstage-plugin-coder/src/plugin.ts | 22 ++- .../src/testHelpers/mockBackstageData.ts | 82 ++++++++- .../src/testHelpers/server.ts | 21 ++- .../src/testHelpers/setup.tsx | 45 +---- .../src/typesConstants.ts | 11 ++ .../src/utils/StateSnapshotManager.ts | 21 ++- yarn.lock | 51 +++--- 23 files changed, 748 insertions(+), 310 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/UrlSync.test.ts create mode 100644 plugins/backstage-plugin-coder/src/api/UrlSync.ts rename plugins/backstage-plugin-coder/src/{ => api}/api.ts (53%) create mode 100644 plugins/backstage-plugin-coder/src/api/errors.ts create mode 100644 plugins/backstage-plugin-coder/src/api/queryOptions.ts delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts create mode 100644 plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index e48c8f21..548df083 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,6 +41,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", + "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, "peerDependencies": { diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts new file mode 100644 index 00000000..7776fadb --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -0,0 +1,90 @@ +import { type UrlSyncSnapshot, UrlSync } from './UrlSync'; +import { type DiscoveryApi } from '@backstage/core-plugin-api'; +import { + getMockConfigApi, + getMockDiscoveryApi, + mockBackstageAssetsEndpoint, + mockBackstageProxyEndpoint, + mockBackstageUrlRoot, +} from '../testHelpers/mockBackstageData'; + +// Tests have to assume that DiscoveryApi and ConfigApi will always be in sync, +// and can be trusted as being equivalent-ish ways of getting at the same source +// of truth. If they're ever not, that's a bug with Backstage itself +describe(`${UrlSync.name}`, () => { + it('Has cached URLs ready to go when instantiated', () => { + const urlSync = new UrlSync({ + apis: { + configApi: getMockConfigApi(), + discoveryApi: getMockDiscoveryApi(), + }, + }); + + const cachedUrls = urlSync.getCachedUrls(); + expect(cachedUrls).toEqual({ + baseUrl: mockBackstageUrlRoot, + apiRoute: mockBackstageProxyEndpoint, + assetsRoute: mockBackstageAssetsEndpoint, + }); + }); + + it('Will update cached URLs if getApiEndpoint starts returning new values (for any reason)', async () => { + let baseUrl = mockBackstageUrlRoot; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => baseUrl, + }; + + const urlSync = new UrlSync({ + apis: { + configApi: getMockConfigApi(), + discoveryApi: mockDiscoveryApi, + }, + }); + + const initialSnapshot = urlSync.getCachedUrls(); + baseUrl = 'blah'; + + await urlSync.getApiEndpoint(); + const newSnapshot = urlSync.getCachedUrls(); + expect(initialSnapshot).not.toEqual(newSnapshot); + + expect(newSnapshot).toEqual({ + baseUrl: 'blah', + apiRoute: 'blah/coder/api/v2', + assetsRoute: 'blah/coder', + }); + }); + + it('Lets external systems subscribe and unsubscribe to cached URL changes', async () => { + let baseUrl = mockBackstageUrlRoot; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => baseUrl, + }; + + const urlSync = new UrlSync({ + apis: { + configApi: getMockConfigApi(), + discoveryApi: mockDiscoveryApi, + }, + }); + + const onChange = jest.fn(); + urlSync.subscribe(onChange); + + baseUrl = 'blah'; + await urlSync.getApiEndpoint(); + + expect(onChange).toHaveBeenCalledWith({ + baseUrl: 'blah', + apiRoute: 'blah/coder/api/v2', + assetsRoute: 'blah/coder', + } satisfies UrlSyncSnapshot); + + urlSync.unsubscribe(onChange); + onChange.mockClear(); + baseUrl = mockBackstageUrlRoot; + + await urlSync.getApiEndpoint(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts new file mode 100644 index 00000000..ae05294b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -0,0 +1,157 @@ +/** + * @file This is basically a fancier version of Backstage's built-in + * DiscoveryApi that is designed to work much better with React. Its hook + * counterpart is useUrlSync. + * + * The class helps with synchronizing URLs between Backstage classes and React + * UI components. It will: + * 1. Make sure URLs are cached so that they can be accessed directly and + * synchronously from the UI + * 2. Make sure that there are mechanisms for binding value changes to React + * state, so that if the URLs change over time, React components can + * re-render correctly + * + * As of April 2024, there are two main built-in ways of getting URLs from + * Backstage config values: + * 1. ConfigApi (offers synchronous methods, but does not have direct access to + * the proxy config - you have to stitch together the full path yourself) + * 2. DiscoveryApi (has access to proxy config, but all methods are async) + * + * Both of these work fine inside event handlers and effects, but are never safe + * to put directly inside render logic. They're not pure functions, so they + * can't be used as derived values, and they don't go through React state, so + * they're completely disconnected from React's render cycles. + */ +import { + type DiscoveryApi, + type ConfigApi, + createApiRef, +} from '@backstage/core-plugin-api'; +import { + type Subscribable, + type SubscriptionCallback, + CODER_API_REF_ID_PREFIX, +} from '../typesConstants'; +import { StateSnapshotManager } from '../utils/StateSnapshotManager'; + +// This is the value we tell people to use inside app-config.yaml +export const CODER_PROXY_PREFIX = '/coder'; + +const BASE_URL_KEY_FOR_CONFIG_API = 'backend.baseUrl'; +const PROXY_URL_KEY_FOR_DISCOVERY_API = 'proxy'; + +type UrlPrefixes = Readonly<{ + proxyPrefix: string; + apiRoutePrefix: string; + assetsRoutePrefix: string; +}>; + +export const defaultUrlPrefixes = { + proxyPrefix: `/api/proxy`, + apiRoutePrefix: '/api/v2', + assetsRoutePrefix: '', // Deliberately left as empty string +} as const satisfies UrlPrefixes; + +export type UrlSyncSnapshot = Readonly<{ + baseUrl: string; + apiRoute: string; + assetsRoute: string; +}>; + +type Subscriber = SubscriptionCallback; + +type ConstructorInputs = Readonly<{ + urlPrefixes?: Partial; + apis: Readonly<{ + discoveryApi: DiscoveryApi; + configApi: ConfigApi; + }>; +}>; + +const proxyRouteReplacer = /\/api\/proxy.*?$/; + +type UrlSyncApi = Subscribable & + Readonly<{ + getApiEndpoint: () => Promise; + getAssetsEndpoint: () => Promise; + getCachedUrls: () => UrlSyncSnapshot; + }>; + +export class UrlSync implements UrlSyncApi { + private readonly discoveryApi: DiscoveryApi; + private readonly urlCache: StateSnapshotManager; + private urlPrefixes: UrlPrefixes; + + constructor(setup: ConstructorInputs) { + const { apis, urlPrefixes = {} } = setup; + const { discoveryApi, configApi } = apis; + + this.discoveryApi = discoveryApi; + this.urlPrefixes = { ...defaultUrlPrefixes, ...urlPrefixes }; + + const proxyRoot = this.getProxyRootFromConfigApi(configApi); + this.urlCache = new StateSnapshotManager({ + initialSnapshot: this.prepareNewSnapshot(proxyRoot), + }); + } + + // ConfigApi is literally only used because it offers a synchronous way to + // get an initial URL to use from inside the constructor. Should not be used + // beyond initial constructor call, so it's not being embedded in the class + private getProxyRootFromConfigApi(configApi: ConfigApi): string { + const baseUrl = configApi.getString(BASE_URL_KEY_FOR_CONFIG_API); + return `${baseUrl}${this.urlPrefixes.proxyPrefix}`; + } + + private prepareNewSnapshot(newProxyUrl: string): UrlSyncSnapshot { + const { assetsRoutePrefix, apiRoutePrefix } = this.urlPrefixes; + + return { + baseUrl: newProxyUrl.replace(proxyRouteReplacer, ''), + assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${assetsRoutePrefix}`, + apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${apiRoutePrefix}`, + }; + } + + /* *************************************************************************** + * All public functions should be defined as arrow functions to ensure they + * can be passed around React without risk of losing their `this` context + ****************************************************************************/ + + getApiEndpoint = async (): Promise => { + const proxyRoot = await this.discoveryApi.getBaseUrl( + PROXY_URL_KEY_FOR_DISCOVERY_API, + ); + + const newSnapshot = this.prepareNewSnapshot(proxyRoot); + this.urlCache.updateSnapshot(newSnapshot); + return newSnapshot.apiRoute; + }; + + getAssetsEndpoint = async (): Promise => { + const proxyRoot = await this.discoveryApi.getBaseUrl( + PROXY_URL_KEY_FOR_DISCOVERY_API, + ); + + const newSnapshot = this.prepareNewSnapshot(proxyRoot); + this.urlCache.updateSnapshot(newSnapshot); + return newSnapshot.assetsRoute; + }; + + getCachedUrls = (): UrlSyncSnapshot => { + return this.urlCache.getSnapshot(); + }; + + unsubscribe = (callback: Subscriber): void => { + this.urlCache.unsubscribe(callback); + }; + + subscribe = (callback: Subscriber): (() => void) => { + this.urlCache.subscribe(callback); + return () => this.unsubscribe(callback); + }; +} + +export const urlSyncApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.url-sync`, +}); diff --git a/plugins/backstage-plugin-coder/src/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts similarity index 53% rename from plugins/backstage-plugin-coder/src/api.ts rename to plugins/backstage-plugin-coder/src/api/api.ts index d11248eb..ac083724 100644 --- a/plugins/backstage-plugin-coder/src/api.ts +++ b/plugins/backstage-plugin-coder/src/api/api.ts @@ -1,26 +1,23 @@ import { parse } from 'valibot'; -import { type UseQueryOptions } from '@tanstack/react-query'; - -import { CoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +import type { IdentityApi } from '@backstage/core-plugin-api'; +import { BackstageHttpError } from './errors'; +import type { UrlSync } from './UrlSync'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import { + type CoderAuth, + assertValidCoderAuth, +} from '../components/CoderProvider'; import { type Workspace, + type WorkspaceAgentStatus, workspaceBuildParametersSchema, workspacesResponseSchema, - WorkspaceAgentStatus, -} from './typesConstants'; -import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider'; -import { IdentityApi } from '@backstage/core-plugin-api'; - -export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; - -const PROXY_ROUTE_PREFIX = '/api/proxy/coder'; -export const API_ROUTE_PREFIX = `${PROXY_ROUTE_PREFIX}/api/v2`; -export const ASSETS_ROUTE_PREFIX = PROXY_ROUTE_PREFIX; +} from '../typesConstants'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; export const REQUEST_TIMEOUT_MS = 20_000; -async function getCoderApiRequestInit( +export async function getCoderApiRequestInit( authToken: string, identity: IdentityApi, ): Promise { @@ -49,34 +46,15 @@ async function getCoderApiRequestInit( }; } -// Makes it easier to expose HTTP responses in the event of errors and also -// gives TypeScript a faster way to type-narrow on those errors -export class BackstageHttpError extends Error { - #response: Response; - - constructor(errorMessage: string, response: Response) { - super(errorMessage); - this.name = 'HttpError'; - this.#response = response; - } - - get status() { - return this.#response.status; - } - - get ok() { - return this.#response.ok; - } - - get contentType() { - return this.#response.headers.get('content_type'); - } -} +type TempPublicUrlSyncApi = Readonly<{ + getApiEndpoint: UrlSync['getApiEndpoint']; + getAssetsEndpoint: UrlSync['getAssetsEndpoint']; +}>; -type FetchInputs = Readonly<{ +export type FetchInputs = Readonly<{ auth: CoderAuth; - baseUrl: string; - identity: IdentityApi; + identityApi: IdentityApi; + urlSyncApi: TempPublicUrlSyncApi; }>; type WorkspacesFetchInputs = Readonly< @@ -85,10 +63,10 @@ type WorkspacesFetchInputs = Readonly< } >; -async function getWorkspaces( +export async function getWorkspaces( fetchInputs: WorkspacesFetchInputs, ): Promise { - const { baseUrl, coderQuery, auth, identity } = fetchInputs; + const { coderQuery, auth, identityApi, urlSyncApi } = fetchInputs; assertValidCoderAuth(auth); const urlParams = new URLSearchParams({ @@ -96,9 +74,10 @@ async function getWorkspaces( limit: '0', }); - const requestInit = await getCoderApiRequestInit(auth.token, identity); + const requestInit = await getCoderApiRequestInit(auth.token, identityApi); + const apiEndpoint = await urlSyncApi.getApiEndpoint(); const response = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/workspaces?${urlParams.toString()}`, + `${apiEndpoint}/workspaces?${urlParams.toString()}`, requestInit, ); @@ -119,6 +98,7 @@ async function getWorkspaces( const json = await response.json(); const { workspaces } = parse(workspacesResponseSchema, json); + const assetsUrl = await urlSyncApi.getAssetsEndpoint(); const withRemappedImgUrls = workspaces.map(ws => { const templateIcon = ws.template_icon; if (!templateIcon.startsWith('/')) { @@ -127,7 +107,7 @@ async function getWorkspaces( return { ...ws, - template_icon: `${baseUrl}${ASSETS_ROUTE_PREFIX}${templateIcon}`, + template_icon: `${assetsUrl}${templateIcon}`, }; }); @@ -141,12 +121,13 @@ type BuildParamsFetchInputs = Readonly< >; async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { baseUrl, auth, workspaceBuildId, identity } = inputs; + const { urlSyncApi, auth, workspaceBuildId, identityApi } = inputs; assertValidCoderAuth(auth); - const requestInit = await getCoderApiRequestInit(auth.token, identity); + const requestInit = await getCoderApiRequestInit(auth.token, identityApi); + const apiEndpoint = await urlSyncApi.getApiEndpoint(); const res = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/workspacebuilds/${workspaceBuildId}/parameters`, + `${apiEndpoint}/workspacebuilds/${workspaceBuildId}/parameters`, requestInit, ); @@ -234,85 +215,3 @@ export function getWorkspaceAgentStatuses( return uniqueStatuses; } - -export function isWorkspaceOnline(workspace: Workspace): boolean { - const latestBuildStatus = workspace.latest_build.status; - const isAvailable = - latestBuildStatus !== 'stopped' && - latestBuildStatus !== 'stopping' && - latestBuildStatus !== 'pending'; - - if (!isAvailable) { - return false; - } - - const statuses = getWorkspaceAgentStatuses(workspace); - return statuses.every( - status => status === 'connected' || status === 'connecting', - ); -} - -export function workspaces( - inputs: WorkspacesFetchInputs, -): UseQueryOptions { - const enabled = inputs.auth.status === 'authenticated'; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery], - queryFn: () => getWorkspaces(inputs), - enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', - }; -} - -export function workspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): UseQueryOptions { - const enabled = - inputs.auth.status === 'authenticated' && inputs.coderQuery !== ''; - - return { - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', inputs.coderQuery, 'repo'], - queryFn: () => getWorkspacesByRepo(inputs), - enabled, - keepPreviousData: enabled, - }; -} - -type AuthValidationInputs = Readonly<{ - baseUrl: string; - authToken: string; - identity: IdentityApi; -}>; - -async function isAuthValid(inputs: AuthValidationInputs): Promise { - const { baseUrl, authToken, identity } = inputs; - - // In this case, the request doesn't actually matter. Just need to make any - // kind of dummy request to validate the auth - const requestInit = await getCoderApiRequestInit(authToken, identity); - const response = await fetch( - `${baseUrl}${API_ROUTE_PREFIX}/users/me`, - requestInit, - ); - - if (response.status >= 400 && response.status !== 401) { - throw new BackstageHttpError('Failed to complete request', response); - } - - return response.status !== 401; -} - -export const authQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; - -export function authValidation( - inputs: AuthValidationInputs, -): UseQueryOptions { - const enabled = inputs.authToken !== ''; - return { - queryKey: [...authQueryKey, inputs.authToken], - queryFn: () => isAuthValid(inputs), - enabled, - keepPreviousData: enabled, - }; -} diff --git a/plugins/backstage-plugin-coder/src/api/errors.ts b/plugins/backstage-plugin-coder/src/api/errors.ts new file mode 100644 index 00000000..924eba6d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/errors.ts @@ -0,0 +1,27 @@ +// Makes it easier to expose HTTP responses in the event of errors and also +// gives TypeScript a faster way to type-narrow on those errors +export class BackstageHttpError extends Error { + #response: Response; + + constructor(errorMessage: string, response: Response) { + super(errorMessage); + this.name = 'HttpError'; + this.#response = response; + } + + static isInstance(value: unknown): value is BackstageHttpError { + return value instanceof BackstageHttpError; + } + + get status() { + return this.#response.status; + } + + get ok() { + return this.#response.ok; + } + + get contentType() { + return this.#response.headers.get('content_type'); + } +} diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts new file mode 100644 index 00000000..a6507790 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -0,0 +1,90 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import type { Workspace } from '../typesConstants'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import { type FetchInputs, getWorkspaces, getWorkspacesByRepo } from './api'; + +export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; + +// Defined here and not in CoderAuthProvider.ts to avoid circular dependency +// issues +export const sharedAuthQueryKey = [CODER_QUERY_KEY_PREFIX, 'auth'] as const; + +const PENDING_REFETCH_INTERVAL_MS = 5_000; +const BACKGROUND_REFETCH_INTERVAL_MS = 60_000; + +function getCoderWorkspacesRefetchInterval( + workspaces?: readonly Workspace[], +): number | false { + if (workspaces === undefined) { + // Boolean false indicates that no periodic refetching should happen (but + // a refetch can still happen in the background in response to user action) + return false; + } + + const areAnyWorkspacesPending = workspaces.some(ws => { + if (ws.latest_build.status === 'pending') { + return true; + } + + return ws.latest_build.resources.some(resource => { + const agents = resource.agents; + return agents?.some(agent => agent.status === 'connecting') ?? false; + }); + }); + + return areAnyWorkspacesPending + ? PENDING_REFETCH_INTERVAL_MS + : BACKGROUND_REFETCH_INTERVAL_MS; +} + +function getSharedWorkspacesQueryKey(coderQuery: string) { + return [CODER_QUERY_KEY_PREFIX, 'workspaces', coderQuery] as const; +} + +type WorkspacesFetchInputs = Readonly< + FetchInputs & { + coderQuery: string; + } +>; + +export function workspaces( + inputs: WorkspacesFetchInputs, +): UseQueryOptions { + const enabled = inputs.auth.isAuthenticated; + + return { + queryKey: getSharedWorkspacesQueryKey(inputs.coderQuery), + queryFn: () => getWorkspaces(inputs), + enabled, + keepPreviousData: enabled && inputs.coderQuery !== '', + refetchInterval: getCoderWorkspacesRefetchInterval, + }; +} + +type WorkspacesByRepoFetchInputs = Readonly< + FetchInputs & { + coderQuery: string; + workspacesConfig: CoderWorkspacesConfig; + } +>; + +export function workspacesByRepo( + inputs: WorkspacesByRepoFetchInputs, +): UseQueryOptions { + // Disabling query object when there is no query text for performance reasons; + // searching through every workspace with an empty string can be incredibly + // slow. + const enabled = + inputs.auth.isAuthenticated && inputs.coderQuery.trim() !== ''; + + return { + queryKey: [ + ...getSharedWorkspacesQueryKey(inputs.coderQuery), + inputs.workspacesConfig, + ], + queryFn: () => getWorkspacesByRepo(inputs), + enabled, + keepPreviousData: enabled, + refetchInterval: getCoderWorkspacesRefetchInterval, + }; +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 8dd9a741..745e6dc2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -11,14 +11,13 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; - +import { BackstageHttpError } from '../../api/errors'; +import { getCoderApiRequestInit } from '../../api/api'; import { - BackstageHttpError, CODER_QUERY_KEY_PREFIX, - authQueryKey, - authValidation, -} from '../../api'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; + sharedAuthQueryKey, +} from '../../api/queryOptions'; +import { useUrlSync } from '../../hooks/useUrlSync'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -99,9 +98,9 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); + const identityApi = useApi(identityApiRef); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); + const { api: urlSyncApi } = useUrlSync(); // Need to split hairs, because the query object can be disabled. Only want to // expose the initializing state if the app mounts with a token already in @@ -109,9 +108,25 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const [authToken, setAuthToken] = useState(readAuthToken); const [readonlyInitialAuthToken] = useState(authToken); - const authValidityQuery = useQuery({ - ...authValidation({ baseUrl, authToken, identity }), + const queryIsEnabled = authToken !== ''; + const authValidityQuery = useQuery({ + queryKey: [...sharedAuthQueryKey, authToken], + enabled: queryIsEnabled, + keepPreviousData: queryIsEnabled, refetchOnWindowFocus: query => query.state.data !== false, + queryFn: async () => { + // In this case, the request doesn't actually matter. Just need to make any + // kind of dummy request to validate the auth + const requestInit = await getCoderApiRequestInit(authToken, identityApi); + const apiEndpoint = await urlSyncApi.getApiEndpoint(); + const response = await fetch(`${apiEndpoint}/users/me`, requestInit); + + if (response.status >= 400 && response.status !== 401) { + throw new BackstageHttpError('Failed to complete request', response); + } + + return response.status !== 401; + }, }); const authState = generateAuthState({ @@ -158,7 +173,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const queryError = event.query.state.error; const shouldRevalidate = !isRefetchingTokenQuery && - queryError instanceof BackstageHttpError && + BackstageHttpError.isInstance(queryError) && queryError.status === 401; if (!shouldRevalidate) { @@ -166,7 +181,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { } isRefetchingTokenQuery = true; - await queryClient.refetchQueries({ queryKey: authQueryKey }); + await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); isRefetchingTokenQuery = false; }); @@ -240,7 +255,7 @@ function generateAuthState({ }; } - if (authValidityQuery.error instanceof BackstageHttpError) { + if (BackstageHttpError.isInstance(authValidityQuery.error)) { const deploymentLikelyUnavailable = authValidityQuery.error.status === 504 || (authValidityQuery.error.status === 200 && diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 41e75bee..1b6b87da 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -5,6 +5,7 @@ import { act, waitFor } from '@testing-library/react'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { configApiRef, + discoveryApiRef, errorApiRef, identityApiRef, } from '@backstage/core-plugin-api'; @@ -15,6 +16,7 @@ import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, + getMockDiscoveryApi, getMockErrorApi, getMockIdentityApi, mockAppConfig, @@ -24,6 +26,7 @@ import { getMockQueryClient, renderHookAsCoderEntity, } from '../../testHelpers/setup'; +import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -56,11 +59,19 @@ describe(`${CoderProvider.name}`, () => { const ParentComponent = ({ children }: PropsWithChildren) => { const configThatChangesEachRender = { ...mockAppConfig }; + const discoveryApi = getMockDiscoveryApi(); + const configApi = getMockConfigApi(); + const urlSyncApi = new UrlSync({ + apis: { discoveryApi, configApi }, + }); + return wrapInTestApp( @@ -87,13 +98,21 @@ describe(`${CoderProvider.name}`, () => { // core to the functionality. In this case, you do need to bring in the full // CoderProvider const renderUseCoderAuth = () => { + const discoveryApi = getMockDiscoveryApi(); + const configApi = getMockConfigApi(); + const urlSyncApi = new UrlSync({ + apis: { discoveryApi, configApi }, + }); + return renderHook(useCoderAuth, { wrapper: ({ children }) => ( & const shouldRetryRequest = (failureCount: number, error: unknown): boolean => { const isBelowThreshold = failureCount < MAX_FETCH_FAILURES; - if (!(error instanceof BackstageHttpError)) { + if (!BackstageHttpError.isInstance(error)) { return isBelowThreshold; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx index 23623a72..079189a9 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListIcon.tsx @@ -1,5 +1,5 @@ import React, { ForwardedRef, HTMLAttributes, useState } from 'react'; -import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints'; +import { useUrlSync } from '../../hooks/useUrlSync'; import { Theme, makeStyles } from '@material-ui/core'; type WorkspaceListIconProps = Readonly< @@ -56,11 +56,8 @@ export const WorkspacesListIcon = ({ ...delegatedProps }: WorkspaceListIconProps) => { const [hasError, setHasError] = useState(false); - const { assetsProxyUrl } = useBackstageEndpoints(); - - const styles = useStyles({ - isEmoji: src.startsWith(`${assetsProxyUrl}/emoji`), - }); + const { renderHelpers } = useUrlSync(); + const styles = useStyles({ isEmoji: renderHelpers.isEmojiUrl(src) }); return (
    { - it('Should provide pre-formatted URLs for interacting with Backstage endpoints', async () => { - const { result } = await renderHookAsCoderEntity(useBackstageEndpoints); - - expect(result.current).toEqual( - expect.objectContaining({ - baseUrl: mockBackstageUrlRoot, - assetsProxyUrl: mockBackstageAssetsEndpoint, - apiProxyUrl: mockBackstageProxyEndpoint, - }), - ); - }); -}); diff --git a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts b/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts deleted file mode 100644 index 7defa50f..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { ASSETS_ROUTE_PREFIX, API_ROUTE_PREFIX } from '../api'; - -export type UseBackstageEndpointResult = Readonly<{ - baseUrl: string; - assetsProxyUrl: string; - apiProxyUrl: string; -}>; - -export function useBackstageEndpoints(): UseBackstageEndpointResult { - const backstageConfig = useApi(configApiRef); - const baseUrl = backstageConfig.getString('backend.baseUrl'); - - return { - baseUrl, - assetsProxyUrl: `${baseUrl}${ASSETS_ROUTE_PREFIX}`, - apiProxyUrl: `${baseUrl}${API_ROUTE_PREFIX}`, - }; -} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 3517ad2b..ea8405bd 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { workspaces, workspacesByRepo } from '../api'; +import { workspaces, workspacesByRepo } from '../api/queryOptions'; import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; -import { useBackstageEndpoints } from './useBackstageEndpoints'; +import { useUrlSync } from './useUrlSync'; import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; @@ -16,19 +16,19 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); - const identity = useApi(identityApiRef); - const { baseUrl } = useBackstageEndpoints(); + const identityApi = useApi(identityApiRef); + const { api: urlSyncApi } = useUrlSync(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData ? workspacesByRepo({ coderQuery, - identity, auth, - baseUrl, + identityApi, + urlSyncApi, workspacesConfig, }) - : workspaces({ coderQuery, identity, auth, baseUrl }); + : workspaces({ coderQuery, auth, identityApi, urlSyncApi }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx new file mode 100644 index 00000000..acc5b282 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { UrlSync, urlSyncApiRef } from '../api/UrlSync'; +import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; +import type { DiscoveryApi } from '@backstage/core-plugin-api'; +import { + mockBackstageAssetsEndpoint, + mockBackstageProxyEndpoint, + mockBackstageUrlRoot, + getMockConfigApi, +} from '../testHelpers/mockBackstageData'; + +function renderUseUrlSync() { + let proxyEndpoint: string = mockBackstageProxyEndpoint; + const mockDiscoveryApi: DiscoveryApi = { + getBaseUrl: async () => proxyEndpoint, + }; + + const urlSync = new UrlSync({ + apis: { + discoveryApi: mockDiscoveryApi, + configApi: getMockConfigApi(), + }, + }); + + const renderResult = renderHook(useUrlSync, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + return { + ...renderResult, + updateMockProxyEndpoint: (newEndpoint: string) => { + proxyEndpoint = newEndpoint; + }, + }; +} + +describe(`${useUrlSync.name}`, () => { + const altProxyUrl = 'http://zombo.com/api/proxy/coder'; + + describe('State', () => { + it('Should provide pre-formatted URLs for interacting with Backstage endpoints', () => { + const { result } = renderUseUrlSync(); + + expect(result.current).toEqual( + expect.objectContaining>({ + state: { + baseUrl: mockBackstageUrlRoot, + assetsRoute: mockBackstageAssetsEndpoint, + apiRoute: mockBackstageProxyEndpoint, + }, + }), + ); + }); + + it('Should re-render when URLs change via the UrlSync class', async () => { + const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const initialState = result.current.state; + + updateMockProxyEndpoint(altProxyUrl); + await act(() => result.current.api.getApiEndpoint()); + const newState = result.current.state; + expect(newState).not.toEqual(initialState); + }); + }); + + describe('Render helpers', () => { + it('isEmojiUrl should correctly detect whether a URL is valid', async () => { + const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + + // Test for URL that is valid and matches the URL from UrlSync + const url1 = `${mockBackstageAssetsEndpoint}/emoji`; + expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(true); + + // Test for URL that is obviously not valid under any circumstances + const url2 = "I don't even know how you could get a URL like this"; + expect(result.current.renderHelpers.isEmojiUrl(url2)).toBe(false); + + // Test for URL that was valid when the React app started up, but then + // UrlSync started giving out a completely different URL + updateMockProxyEndpoint(altProxyUrl); + await act(() => result.current.api.getApiEndpoint()); + expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts new file mode 100644 index 00000000..9ec95ff7 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -0,0 +1,52 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useApi } from '@backstage/core-plugin-api'; +import { + type UrlSyncSnapshot, + type UrlSync, + urlSyncApiRef, +} from '../api/UrlSync'; + +export type UseUrlSyncResult = Readonly<{ + state: UrlSyncSnapshot; + + /** + * @todo This is a temporary property that is being used until the + * CoderClientApi is created, and can consume the UrlSync class directly. + * + * Delete this entire property once the new class is ready. + */ + api: Readonly<{ + getApiEndpoint: UrlSync['getApiEndpoint']; + getAssetsEndpoint: UrlSync['getAssetsEndpoint']; + }>; + + /** + * A collection of functions that can safely be called from within a React + * component's render logic to get derived values. + */ + renderHelpers: { + isEmojiUrl: (url: string) => boolean; + }; +}>; + +export function useUrlSync(): UseUrlSyncResult { + const urlSyncApi = useApi(urlSyncApiRef); + const state = useSyncExternalStore( + urlSyncApi.subscribe, + urlSyncApi.getCachedUrls, + ); + + return { + state, + api: { + getApiEndpoint: urlSyncApi.getApiEndpoint, + getAssetsEndpoint: urlSyncApi.getAssetsEndpoint, + }, + + renderHelpers: { + isEmojiUrl: url => { + return url.startsWith(`${state.assetsRoute}/emoji`); + }, + }, + }; +} diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 7de9929e..ec09da33 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -1,14 +1,30 @@ import { createPlugin, createComponentExtension, + createApiFactory, + discoveryApiRef, + configApiRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; +import { UrlSync, urlSyncApiRef } from './api/UrlSync'; export const coderPlugin = createPlugin({ id: 'coder', - routes: { - root: rootRouteRef, - }, + routes: { root: rootRouteRef }, + apis: [ + createApiFactory({ + api: urlSyncApiRef, + deps: { + discoveryApi: discoveryApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => { + return new UrlSync({ + apis: { discoveryApi, configApi }, + }); + }, + }), + ], }); /** diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 2e0fa6fe..fffd265c 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -1,5 +1,5 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { ConfigReader } from '@backstage/core-app-api'; +import { ConfigReader, FrontendHostDiscovery } from '@backstage/core-app-api'; import { MockConfigApi, MockErrorApi } from '@backstage/test-utils'; import type { ScmIntegrationRegistry } from '@backstage/integration'; /* eslint-enable @backstage/no-undeclared-imports */ @@ -14,10 +14,25 @@ import { CoderWorkspacesConfig, type YamlConfig, } from '../hooks/useCoderWorkspacesConfig'; -import { ScmIntegrationsApi } from '@backstage/integration-react'; - -import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api'; -import { IdentityApi } from '@backstage/core-plugin-api'; +import { + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; +import { + ApiRef, + DiscoveryApi, + IdentityApi, + configApiRef, + discoveryApiRef, + errorApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + CODER_PROXY_PREFIX, + UrlSync, + defaultUrlPrefixes, + urlSyncApiRef, +} from '../api/UrlSync'; /** * This is the key that Backstage checks from the entity data to determine the @@ -51,12 +66,22 @@ export const rawRepoUrl = `${cleanedRepoUrl}/tree/main/`; export const mockBackstageUrlRoot = 'http://localhost:7007'; /** - * The actual endpoint to hit when trying to mock out a server request during - * testing. + * The API endpoint to use with the mock server during testing. + * + * The string literal expression is complicated, but hover over it to see what + * the final result is. */ -export const mockBackstageProxyEndpoint = `${mockBackstageUrlRoot}${API_ROUTE_PREFIX}`; +export const mockBackstageProxyEndpoint = + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; -export const mockBackstageAssetsEndpoint = `${mockBackstageUrlRoot}${ASSETS_ROUTE_PREFIX}`; +/** + * The assets endpoint to use during testing. + * + * The string literal expression is complicated, but hover over it to see what + * the final result is. + */ +export const mockBackstageAssetsEndpoint = + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.assetsRoutePrefix}` as const; export const mockBearerToken = 'This-is-an-opaque-value-by-design'; export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X'; @@ -246,3 +271,42 @@ export function getMockIdentityApi(): IdentityApi { export function getMockSourceControl(): ScmIntegrationRegistry { return ScmIntegrationsApi.fromConfig(new ConfigReader({})); } + +export function getMockDiscoveryApi(): DiscoveryApi { + return FrontendHostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: mockBackstageUrlRoot, + }, + }), + ); +} + +type ApiTuple = readonly [ApiRef>, NonNullable]; + +export function getMockApiList(): readonly ApiTuple[] { + const mockErrorApi = getMockErrorApi(); + const mockSourceControl = getMockSourceControl(); + const mockConfigApi = getMockConfigApi(); + const mockIdentityApi = getMockIdentityApi(); + const mockDiscoveryApi = getMockDiscoveryApi(); + + const mockUrlSyncApi = new UrlSync({ + apis: { + discoveryApi: mockDiscoveryApi, + configApi: mockConfigApi, + }, + }); + + return [ + // APIs that Backstage ships with normally + [errorApiRef, mockErrorApi], + [scmIntegrationsApiRef, mockSourceControl], + [configApiRef, mockConfigApi], + [identityApiRef, mockIdentityApi], + [discoveryApiRef, mockDiscoveryApi], + + // Custom APIs specific to the Coder plugin + [urlSyncApiRef, mockUrlSyncApi], + ]; +} diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 99db7c1b..71d21145 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -20,7 +20,7 @@ import { mockBackstageProxyEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; -import { CODER_AUTH_HEADER_KEY } from '../api'; +import { CODER_AUTH_HEADER_KEY } from '../api/api'; type RestResolver = ResponseResolver< RestRequest, @@ -33,6 +33,16 @@ export type RestResolverMiddleware = ( ) => RestResolver; const defaultMiddleware = [ + function validateCoderSessionToken(handler) { + return (req, res, ctx) => { + const token = req.headers.get(CODER_AUTH_HEADER_KEY); + if (token === mockCoderAuthToken) { + return handler(req, res, ctx); + } + + return res(ctx.status(401)); + }; + }, function validateBearerToken(handler) { return (req, res, ctx) => { const tokenRe = /^Bearer (.+)$/; @@ -104,13 +114,8 @@ const mainTestHandlers: readonly RestHandler[] = [ ), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (req, res, ctx) => { - const token = req.headers.get(CODER_AUTH_HEADER_KEY); - if (token === mockCoderAuthToken) { - return res(ctx.status(200)); - } - - return res(ctx.status(401)); + wrappedGet(`${root}/users/me`, (_, res, ctx) => { + return res(ctx.status(200)); }), ]; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 70afba5b..0cef032f 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -11,12 +11,6 @@ import { import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { - configApiRef, - errorApiRef, - identityApiRef, -} from '@backstage/core-plugin-api'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { type CoderAuth, @@ -27,14 +21,11 @@ import { CoderAppConfigProvider, } from '../components/CoderProvider'; import { - getMockSourceControl, mockAppConfig, mockEntity, - getMockErrorApi, - getMockConfigApi, mockAuthStates, BackstageEntity, - getMockIdentityApi, + getMockApiList, } from './mockBackstageData'; import { CoderErrorBoundary } from '../plugin'; @@ -161,24 +152,13 @@ export const renderHookAsCoderEntity = async < options?: RenderHookAsCoderEntityOptions, ): Promise> => { const { authStatus, ...delegatedOptions } = options ?? {}; - const mockErrorApi = getMockErrorApi(); - const mockSourceControl = getMockSourceControl(); - const mockConfigApi = getMockConfigApi(); - const mockIdentityApi = getMockIdentityApi(); const mockQueryClient = getMockQueryClient(); const renderHookValue = renderHook(hook, { ...delegatedOptions, wrapper: ({ children }) => { const mainMarkup = ( - + + ; +export type SubscriptionCallback = (value: T) => void; +export interface Subscribable { + subscribe: (callback: SubscriptionCallback) => () => void; + unsubscribe: (callback: SubscriptionCallback) => void; +} + +/** + * The prefix to use for all Backstage API refs created for the Coder plugin. + */ +export const CODER_API_REF_ID_PREFIX = 'backstage-plugin-coder'; + export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; export const workspaceAgentStatusSchema = union([ diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts index a109909d..1493c907 100644 --- a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts @@ -12,11 +12,11 @@ * into this class. It will then take care of notifying subscriptions, while * reconciling old/new snapshots to minimize needless re-renders. */ -import type { ReadonlyJsonValue } from '../typesConstants'; - -type SubscriptionCallback = ( - snapshot: TSnapshot, -) => void; +import type { + ReadonlyJsonValue, + SubscriptionCallback, + Subscribable, +} from '../typesConstants'; type DidSnapshotsChange = ( oldSnapshot: TSnapshot, @@ -33,12 +33,11 @@ type SnapshotManagerOptions = Readonly<{ didSnapshotsChange?: DidSnapshotsChange; }>; -interface SnapshotManagerApi { - subscribe: (callback: SubscriptionCallback) => () => void; - unsubscribe: (callback: SubscriptionCallback) => void; - getSnapshot: () => TSnapshot; - updateSnapshot: (newSnapshot: TSnapshot) => void; -} +type SnapshotManagerApi = + Subscribable & { + getSnapshot: () => TSnapshot; + updateSnapshot: (newSnapshot: TSnapshot) => void; + }; function areSameByReference(v1: unknown, v2: unknown) { // Comparison looks wonky, but Object.is handles more edge cases than === diff --git a/yarn.lock b/yarn.lock index a60186cb..b060021e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8713,7 +8713,7 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": +"@types/react-dom@*", "@types/react-dom@^18.0.0": version "18.2.21" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== @@ -8751,7 +8751,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": +"@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": version "18.2.64" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== @@ -8760,6 +8760,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^16.13.1 || ^17.0.0": + version "17.0.80" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.80.tgz#a5dfc351d6a41257eb592d73d3a85d3b7dbcbb41" + integrity sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "^0.16" + csstype "^3.0.2" + "@types/request@^2.47.1", "@types/request@^2.48.8": version "2.48.12" resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.12.tgz#0f590f615a10f87da18e9790ac94c29ec4c5ef30" @@ -8787,7 +8796,7 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*": +"@types/scheduler@*", "@types/scheduler@^0.16": version "0.16.8" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== @@ -21890,16 +21899,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21973,7 +21973,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21987,13 +21987,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23233,6 +23226,11 @@ use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== +use-sync-external-store@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.1.tgz#8a64ce640415ae9944ec9e8336a8544bb77dcff2" + integrity sha512-6MCBDr76UJmRpbF8pzP27uIoTocf3tITaMJ52mccgAhMJycuh5A/RL6mDZCTwTisj0Qfeq69FtjMCUX27U78oA== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -23797,7 +23795,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23815,15 +23813,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 74e7dd311f7c31566ef7788b7c72d50d9f314616 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 30 Apr 2024 13:44:30 -0400 Subject: [PATCH 21/33] refactor: consolidate API logic into CoderClient class (#125) * wip: commit progress on UrlSync class/hook * refactor: consolidate emoji-testing logic * docs: update comments for clarity * refactor: rename helpers to renderHelpers * wip: finish initial implementation of UrlSync * chore: finish tests for UrlSync class * chore: add mock DiscoveryApi helper * chore: finish tests for useUrlSync * refactor: consolidate mock URL logic for useUrlSync * fix: update test helper to use API list * fix: remove unneeded imports * fix: get tests for all current code passing * fix: remove typo * fix: update useUrlSync to expose underlying api * refactor: increase data hiding for hook * fix: make useUrlSync tests less dependent on implementation details * refactor: remove reliance on baseUrl argument for fetch calls * refactor: split Backstage error type into separate file * refactor: clean up imports for api file * refactor: split main query options into separate file * consolidate how mock endpoints are defined * fix: remove base URL from auth calls * refactor: consolidate almost all auth logic into CoderAuthProvider * move api file into api directory * fix: revert prop that was changed for debugging * fix: revert prop definition * refactor: extract token-checking logic into middleware for server * refactor: move shared auth key to queryOptions file * docs: add reminder about arrow functions * wip: add initial versions of CoderClient code * wip: delete entire api.ts file * fix: remove temp api escape hatch for useUrlSync * chore: update syncToken logic to use temporary interceptors * refactor: update variable name for clarity * fix: prevent double-cancellation of timeout signals * fix: cleanup timeout logic * refactor: split pseudo-SDK into separate file * fix: resolve issue with conflicting interceptors * chore: improve cleanup logic * fix: update majority of breaking tests * fix: resolve all breaking tests * fix: beef up CoderClient validation logic * chore: commit first passing test for CoderClient * fix: update error-detection logic in test * wip: add all test stubs for CoderClient * chore: add test cases for syncToken's main return type * chore: add more test cases * fix: remove Object.freeze logic * refactor: consolidate mock API endpoints in one spot * wip: commit current test progress * refactor: rename mock API endpoint variable for clarity * chore: finish test for aborting queued requests * chore: finish initial versions of all CoderClient tests * fix: delete helper that was never used * fix: update getWorkspacesByRepo function signature to be more consistent with base function * docs: add comment reminder about arrow functions for CoderClient * docs: add comment explaining use of interceptor logic * fix: update return type of getWorkspacesByRepo function * fix: remove configApi from embedded class properties * fix: update query logic to remove any whitespace * refactor: simplify interceptor removal logic * refactor: update how Backstage SDK is set up * refactor: update dummy request for authenticating * fix: add user parsing logic to CoderClient --- plugins/backstage-plugin-coder/package.json | 1 + .../src/api/CoderClient.test.ts | 215 ++++++++++ .../src/api/CoderClient.ts | 375 ++++++++++++++++++ .../src/api/MockCoderSdk.ts | 62 +++ .../src/api/UrlSync.test.ts | 4 +- plugins/backstage-plugin-coder/src/api/api.ts | 217 ---------- .../src/api/queryOptions.ts | 66 +-- .../CoderProvider/CoderAuthProvider.tsx | 26 +- .../CoderProvider/CoderProvider.test.tsx | 58 +-- .../WorkspacesListIcon.test.tsx | 4 +- .../WorkspacesListItem.tsx | 2 +- .../src/hooks/useCoderSdk.ts | 7 + .../src/hooks/useCoderWorkspacesQuery.ts | 21 +- .../src/hooks/useUrlSync.test.tsx | 16 +- .../src/hooks/useUrlSync.ts | 22 +- plugins/backstage-plugin-coder/src/plugin.ts | 14 + .../src/testHelpers/mockBackstageData.ts | 12 +- .../src/testHelpers/mockCoderAppData.ts | 10 +- .../src/testHelpers/server.ts | 46 ++- .../src/typesConstants.ts | 17 + .../backstage-plugin-coder/src/utils/time.ts | 9 + .../src/utils/workspaces.ts | 22 + yarn.lock | 14 + 23 files changed, 860 insertions(+), 380 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/CoderClient.test.ts create mode 100644 plugins/backstage-plugin-coder/src/api/CoderClient.ts create mode 100644 plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts delete mode 100644 plugins/backstage-plugin-coder/src/api/api.ts create mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts create mode 100644 plugins/backstage-plugin-coder/src/utils/time.ts create mode 100644 plugins/backstage-plugin-coder/src/utils/workspaces.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 548df083..6dcc24a8 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -41,6 +41,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", + "axios": "^1.6.8", "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts new file mode 100644 index 00000000..945d8317 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -0,0 +1,215 @@ +import { + CODER_AUTH_HEADER_KEY, + CoderClient, + disabledClientError, +} from './CoderClient'; +import type { IdentityApi } from '@backstage/core-plugin-api'; +import { UrlSync } from './UrlSync'; +import { rest } from 'msw'; +import { mockServerEndpoints, server, wrappedGet } from '../testHelpers/server'; +import { CanceledError } from 'axios'; +import { delay } from '../utils/time'; +import { mockWorkspacesList } from '../testHelpers/mockCoderAppData'; +import type { Workspace, WorkspacesResponse } from '../typesConstants'; +import { + getMockConfigApi, + getMockDiscoveryApi, + getMockIdentityApi, + mockCoderAuthToken, + mockCoderWorkspacesConfig, +} from '../testHelpers/mockBackstageData'; + +type ConstructorApis = Readonly<{ + identityApi: IdentityApi; + urlSync: UrlSync; +}>; + +function getConstructorApis(): ConstructorApis { + const configApi = getMockConfigApi(); + const discoveryApi = getMockDiscoveryApi(); + const urlSync = new UrlSync({ + apis: { configApi, discoveryApi }, + }); + + const identityApi = getMockIdentityApi(); + return { urlSync, identityApi }; +} + +describe(`${CoderClient.name}`, () => { + describe('syncToken functionality', () => { + it('Will load the provided token into the client if it is valid', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + + const syncResult = await client.syncToken(mockCoderAuthToken); + expect(syncResult).toBe(true); + + let serverToken: string | null = null; + server.use( + rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => { + serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); + return res(ctx.status(200)); + }), + ); + + await client.sdk.getAuthenticatedUser(); + expect(serverToken).toBe(mockCoderAuthToken); + }); + + it('Will NOT load the provided token into the client if it is invalid', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + + const syncResult = await client.syncToken('Definitely not valid'); + expect(syncResult).toBe(false); + + let serverToken: string | null = null; + server.use( + rest.get(mockServerEndpoints.authenticatedUser, (req, res, ctx) => { + serverToken = req.headers.get(CODER_AUTH_HEADER_KEY); + return res(ctx.status(200)); + }), + ); + + await client.sdk.getAuthenticatedUser(); + expect(serverToken).toBe(null); + }); + + it('Will propagate any other error types to the caller', async () => { + const client = new CoderClient({ + // Setting the timeout to 0 will make requests instantly fail from the + // next microtask queue tick + requestTimeoutMs: 0, + apis: getConstructorApis(), + }); + + server.use( + rest.get(mockServerEndpoints.authenticatedUser, async (_, res, ctx) => { + // MSW is so fast that sometimes it can respond before a forced + // timeout; have to introduce artificial delay (that shouldn't matter + // as long as the abort logic goes through properly) + await delay(2_000); + return res(ctx.status(200)); + }), + ); + + await expect(() => { + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(CanceledError); + }); + }); + + describe('cleanupClient functionality', () => { + it('Will prevent any new SDK requests from going through', async () => { + const client = new CoderClient({ apis: getConstructorApis() }); + client.cleanupClient(); + + // Request should fail, even though token is valid + await expect(() => { + return client.syncToken(mockCoderAuthToken); + }).rejects.toThrow(disabledClientError); + + await expect(() => { + return client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + }).rejects.toThrow(disabledClientError); + }); + + it('Will abort any pending requests', async () => { + const client = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: getConstructorApis(), + }); + + // Sanity check to ensure that request can still go through normally + const workspacesPromise1 = client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + await expect(workspacesPromise1).resolves.toEqual({ + workspaces: mockWorkspacesList, + count: mockWorkspacesList.length, + }); + + const workspacesPromise2 = client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + client.cleanupClient(); + await expect(() => workspacesPromise2).rejects.toThrow(); + }); + }); + + // Eventually the Coder SDK is going to get too big to test every single + // function. Focus tests on the functionality specifically being patched in + // for Backstage + describe('Coder SDK', () => { + it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => { + const apis = getConstructorApis(); + const client = new CoderClient({ + apis, + initialToken: mockCoderAuthToken, + }); + + server.use( + wrappedGet(mockServerEndpoints.workspaces, (_, res, ctx) => { + const withRelativePaths = mockWorkspacesList.map(ws => { + return { + ...ws, + template_icon: '/emojis/blueberry.svg', + }; + }); + + return res( + ctx.status(200), + ctx.json({ + workspaces: withRelativePaths, + count: withRelativePaths.length, + }), + ); + }), + ); + + const { workspaces } = await client.sdk.getWorkspaces({ + q: 'owner:me', + limit: 0, + }); + + const { urlSync } = apis; + const apiEndpoint = await urlSync.getApiEndpoint(); + + const allWorkspacesAreRemapped = !workspaces.some(ws => + ws.template_icon.startsWith(apiEndpoint), + ); + + expect(allWorkspacesAreRemapped).toBe(true); + }); + + it('Lets the user search for workspaces by repo URL', async () => { + const client = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: getConstructorApis(), + }); + + const { workspaces } = await client.sdk.getWorkspacesByRepo( + { q: 'owner:me' }, + mockCoderWorkspacesConfig, + ); + + const buildParameterGroups = await Promise.all( + workspaces.map(ws => + client.sdk.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + for (const paramGroup of buildParameterGroups) { + const atLeastOneParamMatchesForGroup = paramGroup.some(param => { + return param.value === mockCoderWorkspacesConfig.repoUrl; + }); + + expect(atLeastOneParamMatchesForGroup).toBe(true); + } + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts new file mode 100644 index 00000000..047c08ca --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -0,0 +1,375 @@ +import globalAxios, { + AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig as RequestConfig, +} from 'axios'; +import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; +import { + type Workspace, + CODER_API_REF_ID_PREFIX, + WorkspacesRequest, + WorkspacesResponse, + User, +} from '../typesConstants'; +import type { UrlSync } from './UrlSync'; +import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; +import { CoderSdk } from './MockCoderSdk'; + +export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; +const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; + +/** + * A version of the main Coder SDK API, with additional Backstage-specific + * methods and properties. + */ +export type BackstageCoderSdk = Readonly< + CoderSdk & { + getWorkspacesByRepo: ( + request: WorkspacesRequest, + config: CoderWorkspacesConfig, + ) => Promise; + } +>; + +type CoderClientApi = Readonly<{ + sdk: BackstageCoderSdk; + + /** + * Validates a new token, and loads it only if it is valid. + * Return value indicates whether the token is valid. + */ + syncToken: (newToken: string) => Promise; + + /** + * Cleans up a client instance, removing its links to all external systems. + */ + cleanupClient: () => void; +}>; + +const sharedCleanupAbortReason = new DOMException( + 'Coder Client instance has been manually cleaned up', + 'AbortError', +); + +// Can't make this value readonly at the type level because it has +// non-enumerable properties, and Object.freeze causes errors. Just have to +// treat this like a constant +export const disabledClientError = new Error( + 'Requests have been disabled for this client. Please create a new client', +); + +type ConstructorInputs = Readonly<{ + initialToken?: string; + requestTimeoutMs?: number; + + apis: Readonly<{ + urlSync: UrlSync; + identityApi: IdentityApi; + }>; +}>; + +export class CoderClient implements CoderClientApi { + private readonly urlSync: UrlSync; + private readonly identityApi: IdentityApi; + private readonly axios: AxiosInstance; + + private readonly requestTimeoutMs: number; + private readonly cleanupController: AbortController; + private readonly trackedEjectionIds: Set; + + private loadedSessionToken: string | undefined; + readonly sdk: BackstageCoderSdk; + + constructor(inputs: ConstructorInputs) { + const { + apis, + initialToken, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + } = inputs; + const { urlSync, identityApi } = apis; + + this.urlSync = urlSync; + this.identityApi = identityApi; + this.axios = globalAxios.create(); + + this.loadedSessionToken = initialToken; + this.requestTimeoutMs = requestTimeoutMs; + + this.cleanupController = new AbortController(); + this.trackedEjectionIds = new Set(); + + this.sdk = this.getBackstageCoderSdk(this.axios); + this.addBaseRequestInterceptors(); + } + + private addRequestInterceptor( + requestInterceptor: ( + config: RequestConfig, + ) => RequestConfig | Promise, + errorInterceptor?: (error: unknown) => unknown, + ): number { + const ejectionId = this.axios.interceptors.request.use( + requestInterceptor, + errorInterceptor, + ); + + this.trackedEjectionIds.add(ejectionId); + return ejectionId; + } + + private removeRequestInterceptorById(ejectionId: number): boolean { + // Even if we somehow pass in an ID that hasn't been associated with the + // Axios instance, that's a noop. No harm in calling method no matter what + this.axios.interceptors.request.eject(ejectionId); + + if (!this.trackedEjectionIds.has(ejectionId)) { + return false; + } + + this.trackedEjectionIds.delete(ejectionId); + return true; + } + + private addBaseRequestInterceptors(): void { + // Configs exist on a per-request basis; mutating the config for a new + // request won't mutate any configs for requests that are currently pending + const baseRequestInterceptor = async ( + config: RequestConfig, + ): Promise => { + // Front-load the setup steps that rely on external APIs, so that if any + // fail, the request bails out early before modifying the config + const proxyApiEndpoint = await this.urlSync.getApiEndpoint(); + const bearerToken = (await this.identityApi.getCredentials()).token; + + config.baseURL = proxyApiEndpoint; + config.signal = this.getTimeoutAbortSignal(); + + // The Axios docs have incredibly confusing wording about how multiple + // interceptors work. They say the interceptors are "run in the order + // added", implying that the first interceptor you add will always run + // first. That is not true - they're run in reverse order, so the newer + // interceptors will always run before anything else. Only add token from + // this base interceptor if a newer interceptor hasn't already added one + if (config.headers[CODER_AUTH_HEADER_KEY] === undefined) { + config.headers[CODER_AUTH_HEADER_KEY] = this.loadedSessionToken; + } + + if (bearerToken) { + config.headers.Authorization = `Bearer ${bearerToken}`; + } + + return config; + }; + + const baseErrorInterceptor = (error: unknown): unknown => { + const errorIsFromCleanup = + error instanceof DOMException && + error.name === sharedCleanupAbortReason.name && + error.message === sharedCleanupAbortReason.message; + + // Manually aborting a request is always treated as an error, even if we + // 100% expect it. Just scrub the error if it's from the cleanup + if (errorIsFromCleanup) { + return undefined; + } + + return error; + }; + + this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); + } + + private getBackstageCoderSdk( + axiosInstance: AxiosInstance, + ): BackstageCoderSdk { + const baseSdk = new CoderSdk(axiosInstance); + + const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { + const workspacesRes = await baseSdk.getWorkspaces(request); + const remapped = await this.remapWorkspaceIconUrls( + workspacesRes.workspaces, + ); + + return { + ...workspacesRes, + workspaces: remapped, + }; + }; + + const getWorkspacesByRepo = async ( + request: WorkspacesRequest, + config: CoderWorkspacesConfig, + ): Promise => { + const { workspaces } = await baseSdk.getWorkspaces(request); + const paramResults = await Promise.allSettled( + workspaces.map(ws => + this.sdk.getWorkspaceBuildParameters(ws.latest_build.id), + ), + ); + + const matchedWorkspaces: Workspace[] = []; + for (const [index, res] of paramResults.entries()) { + if (res.status === 'rejected') { + continue; + } + + for (const param of res.value) { + const include = + config.repoUrlParamKeys.includes(param.name) && + param.value === config.repoUrl; + + if (include) { + // Doing type assertion just in case noUncheckedIndexedAccess + // compiler setting ever gets turned on; this shouldn't ever break, + // but it's technically not type-safe + matchedWorkspaces.push(workspaces[index] as Workspace); + break; + } + } + } + + return { + workspaces: matchedWorkspaces, + count: matchedWorkspaces.length, + }; + }; + + return { + ...baseSdk, + getWorkspaces, + getWorkspacesByRepo, + }; + } + + /** + * Creates a combined abort signal that will abort when the client is cleaned + * up, but will also enforce request timeouts + */ + private getTimeoutAbortSignal(): AbortSignal { + // AbortSignal.any would do exactly what we need to, but it's too new for + // certain browsers to be reliable. Have to wire everything up manually + const timeoutController = new AbortController(); + + const timeoutId = window.setTimeout(() => { + const reason = new DOMException('Signal timed out', 'TimeoutException'); + timeoutController.abort(reason); + }, this.requestTimeoutMs); + + const cleanupSignal = this.cleanupController.signal; + cleanupSignal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeoutId); + timeoutController.abort(cleanupSignal.reason); + }, + + // Attaching the timeoutController signal here makes it so that if the + // timeout resolves, this event listener will automatically be removed + { signal: timeoutController.signal }, + ); + + return timeoutController.signal; + } + + private async remapWorkspaceIconUrls( + workspaces: readonly Workspace[], + ): Promise { + const assetsRoute = await this.urlSync.getAssetsEndpoint(); + + return workspaces.map(ws => { + const templateIconUrl = ws.template_icon; + if (!templateIconUrl.startsWith('/')) { + return ws; + } + + return { + ...ws, + template_icon: `${assetsRoute}${templateIconUrl}`, + }; + }); + } + + /* *************************************************************************** + * All public functions should be defined as arrow functions to ensure they + * can be passed around React without risk of losing their `this` context + ****************************************************************************/ + + syncToken = async (newToken: string): Promise => { + // Because this newly-added interceptor will run before any other + // interceptors, you could make it so that the syncToken request will + // disable all other requests while validating. Chose not to do that because + // of React Query background re-fetches. As long as the new token is valid, + // they won't notice any difference at all, even though the token will have + // suddenly changed out from under them + const validationId = this.addRequestInterceptor(config => { + config.headers[CODER_AUTH_HEADER_KEY] = newToken; + return config; + }); + + try { + // Actual request type doesn't matter; just need to make some kind of + // dummy request. Should favor requests that all users have access to and + // that don't require request bodies + const dummyUser = await this.sdk.getAuthenticatedUser(); + + // Most of the time, we're going to trust the types returned back from the + // server without doing any type-checking, but because this request does + // deal with auth, we're going to do some extra validation steps + assertValidUser(dummyUser); + + this.loadedSessionToken = newToken; + return true; + } catch (err) { + const tokenIsInvalid = + err instanceof AxiosError && err.response?.status === 401; + if (tokenIsInvalid) { + return false; + } + + throw err; + } finally { + // Logic in finally blocks always run, even after the function has + // returned a value or thrown an error + this.removeRequestInterceptorById(validationId); + } + }; + + cleanupClient = (): void => { + this.trackedEjectionIds.forEach(id => { + this.axios.interceptors.request.eject(id); + }); + + this.trackedEjectionIds.clear(); + this.cleanupController.abort(sharedCleanupAbortReason); + this.loadedSessionToken = undefined; + + // Not using this.addRequestInterceptor, because we don't want to track this + // interceptor at all. It should never be ejected once the client has been + // disabled + this.axios.interceptors.request.use(() => { + throw disabledClientError; + }); + }; +} + +function assertValidUser(value: unknown): asserts value is User { + if (value === null || typeof value !== 'object') { + throw new Error('Returned JSON value is not an object'); + } + + const hasFields = + 'id' in value && + typeof value.id === 'string' && + 'username' in value && + typeof value.username === 'string'; + + if (!hasFields) { + throw new Error( + 'User object is missing expected fields for authentication request', + ); + } +} + +export const coderClientApiRef = createApiRef({ + id: `${CODER_API_REF_ID_PREFIX}.coder-client`, +}); diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts new file mode 100644 index 00000000..4245a65a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -0,0 +1,62 @@ +/** + * @file This is a temporary (and significantly limited) implementation of the + * "Coder SDK" that will eventually be imported from Coder core + * + * @todo Replace this with a full, proper implementation, and then expose it to + * plugin users. + */ +import globalAxios, { type AxiosInstance } from 'axios'; +import { + type User, + type WorkspacesRequest, + type WorkspaceBuildParameter, + type WorkspacesResponse, +} from '../typesConstants'; + +type CoderSdkApi = { + getAuthenticatedUser: () => Promise; + getWorkspaces: (request: WorkspacesRequest) => Promise; + getWorkspaceBuildParameters: ( + workspaceBuildId: string, + ) => Promise; +}; + +export class CoderSdk implements CoderSdkApi { + private readonly axios: AxiosInstance; + + constructor(axiosInstance?: AxiosInstance) { + this.axios = axiosInstance ?? globalAxios.create(); + } + + getWorkspaces = async ( + request: WorkspacesRequest, + ): Promise => { + const urlParams = new URLSearchParams({ + q: request.q ?? '', + limit: String(request.limit || 0), + after_id: request.after_id ?? '', + offset: String(request.offset || 0), + }); + + const response = await this.axios.get( + `/workspaces?${urlParams.toString()}`, + ); + + return response.data; + }; + + getWorkspaceBuildParameters = async ( + workspaceBuildId: string, + ): Promise => { + const response = await this.axios.get( + `/workspacebuilds/${workspaceBuildId}/parameters`, + ); + + return response.data; + }; + + getAuthenticatedUser = async (): Promise => { + const response = await this.axios.get('/users/me'); + return response.data; + }; +} diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 7776fadb..4932edea 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -4,7 +4,7 @@ import { getMockConfigApi, getMockDiscoveryApi, mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, + mockBackstageApiEndpoint, mockBackstageUrlRoot, } from '../testHelpers/mockBackstageData'; @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageProxyEndpoint, + apiRoute: mockBackstageApiEndpoint, assetsRoute: mockBackstageAssetsEndpoint, }); }); diff --git a/plugins/backstage-plugin-coder/src/api/api.ts b/plugins/backstage-plugin-coder/src/api/api.ts deleted file mode 100644 index ac083724..00000000 --- a/plugins/backstage-plugin-coder/src/api/api.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { parse } from 'valibot'; -import type { IdentityApi } from '@backstage/core-plugin-api'; -import { BackstageHttpError } from './errors'; -import type { UrlSync } from './UrlSync'; -import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { - type CoderAuth, - assertValidCoderAuth, -} from '../components/CoderProvider'; -import { - type Workspace, - type WorkspaceAgentStatus, - workspaceBuildParametersSchema, - workspacesResponseSchema, -} from '../typesConstants'; - -export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; -export const REQUEST_TIMEOUT_MS = 20_000; - -export async function getCoderApiRequestInit( - authToken: string, - identity: IdentityApi, -): Promise { - const headers: HeadersInit = { - [CODER_AUTH_HEADER_KEY]: authToken, - }; - - try { - const credentials = await identity.getCredentials(); - if (credentials.token) { - headers.Authorization = `Bearer ${credentials.token}`; - } - } catch (err) { - if (err instanceof Error) { - throw err; - } - - throw new Error( - "Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API", - ); - } - - return { - headers, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }; -} - -type TempPublicUrlSyncApi = Readonly<{ - getApiEndpoint: UrlSync['getApiEndpoint']; - getAssetsEndpoint: UrlSync['getAssetsEndpoint']; -}>; - -export type FetchInputs = Readonly<{ - auth: CoderAuth; - identityApi: IdentityApi; - urlSyncApi: TempPublicUrlSyncApi; -}>; - -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; - -export async function getWorkspaces( - fetchInputs: WorkspacesFetchInputs, -): Promise { - const { coderQuery, auth, identityApi, urlSyncApi } = fetchInputs; - assertValidCoderAuth(auth); - - const urlParams = new URLSearchParams({ - q: coderQuery, - limit: '0', - }); - - const requestInit = await getCoderApiRequestInit(auth.token, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const response = await fetch( - `${apiEndpoint}/workspaces?${urlParams.toString()}`, - requestInit, - ); - - if (!response.ok) { - throw new BackstageHttpError( - `Unable to retrieve workspaces for query (${coderQuery})`, - response, - ); - } - - if (!response.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - response, - ); - } - - const json = await response.json(); - const { workspaces } = parse(workspacesResponseSchema, json); - - const assetsUrl = await urlSyncApi.getAssetsEndpoint(); - const withRemappedImgUrls = workspaces.map(ws => { - const templateIcon = ws.template_icon; - if (!templateIcon.startsWith('/')) { - return ws; - } - - return { - ...ws, - template_icon: `${assetsUrl}${templateIcon}`, - }; - }); - - return withRemappedImgUrls; -} - -type BuildParamsFetchInputs = Readonly< - FetchInputs & { - workspaceBuildId: string; - } ->; - -async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) { - const { urlSyncApi, auth, workspaceBuildId, identityApi } = inputs; - assertValidCoderAuth(auth); - - const requestInit = await getCoderApiRequestInit(auth.token, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const res = await fetch( - `${apiEndpoint}/workspacebuilds/${workspaceBuildId}/parameters`, - requestInit, - ); - - if (!res.ok) { - throw new BackstageHttpError( - `Failed to retreive build params for workspace ID ${workspaceBuildId}`, - res, - ); - } - - if (!res.headers.get('content-type')?.includes('application/json')) { - throw new BackstageHttpError( - '200 request has no data - potential proxy issue', - res, - ); - } - - const json = await res.json(); - return parse(workspaceBuildParametersSchema, json); -} - -type WorkspacesByRepoFetchInputs = Readonly< - WorkspacesFetchInputs & { - workspacesConfig: CoderWorkspacesConfig; - } ->; - -export async function getWorkspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): Promise { - const workspaces = await getWorkspaces(inputs); - - const paramResults = await Promise.allSettled( - workspaces.map(ws => - getWorkspaceBuildParameters({ - ...inputs, - workspaceBuildId: ws.latest_build.id, - }), - ), - ); - - const { workspacesConfig } = inputs; - const matchedWorkspaces: Workspace[] = []; - - for (const [index, res] of paramResults.entries()) { - if (res.status === 'rejected') { - continue; - } - - for (const param of res.value) { - const include = - workspacesConfig.repoUrlParamKeys.includes(param.name) && - param.value === workspacesConfig.repoUrl; - - if (include) { - // Doing type assertion just in case noUncheckedIndexedAccess compiler - // setting ever gets turned on; this shouldn't ever break, but it's - // technically not type-safe - matchedWorkspaces.push(workspaces[index] as Workspace); - break; - } - } - } - - return matchedWorkspaces; -} - -export function getWorkspaceAgentStatuses( - workspace: Workspace, -): readonly WorkspaceAgentStatus[] { - const uniqueStatuses: WorkspaceAgentStatus[] = []; - - for (const resource of workspace.latest_build.resources) { - if (resource.agents === undefined) { - continue; - } - - for (const agent of resource.agents) { - const status = agent.status; - if (!uniqueStatuses.includes(status)) { - uniqueStatuses.push(status); - } - } - } - - return uniqueStatuses; -} diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index a6507790..b10ecfe2 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,7 +1,8 @@ import type { UseQueryOptions } from '@tanstack/react-query'; -import type { Workspace } from '../typesConstants'; +import type { Workspace, WorkspacesRequest } from '../typesConstants'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { type FetchInputs, getWorkspaces, getWorkspacesByRepo } from './api'; +import type { BackstageCoderSdk } from './CoderClient'; +import type { CoderAuth } from '../components/CoderProvider'; export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; @@ -41,50 +42,61 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { return [CODER_QUERY_KEY_PREFIX, 'workspaces', coderQuery] as const; } -type WorkspacesFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; - } ->; +type WorkspacesFetchInputs = Readonly<{ + auth: CoderAuth; + coderSdk: BackstageCoderSdk; + coderQuery: string; +}>; -export function workspaces( - inputs: WorkspacesFetchInputs, -): UseQueryOptions { - const enabled = inputs.auth.isAuthenticated; +export function workspaces({ + auth, + coderSdk, + coderQuery, +}: WorkspacesFetchInputs): UseQueryOptions { + const enabled = auth.isAuthenticated; return { - queryKey: getSharedWorkspacesQueryKey(inputs.coderQuery), - queryFn: () => getWorkspaces(inputs), + queryKey: getSharedWorkspacesQueryKey(coderQuery), enabled, - keepPreviousData: enabled && inputs.coderQuery !== '', + keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: async () => { + const res = await coderSdk.getWorkspaces({ + q: coderQuery, + limit: 0, + }); + + return res.workspaces; + }, }; } type WorkspacesByRepoFetchInputs = Readonly< - FetchInputs & { - coderQuery: string; + WorkspacesFetchInputs & { workspacesConfig: CoderWorkspacesConfig; } >; -export function workspacesByRepo( - inputs: WorkspacesByRepoFetchInputs, -): UseQueryOptions { - // Disabling query object when there is no query text for performance reasons; +export function workspacesByRepo({ + coderQuery, + coderSdk, + auth, + workspacesConfig, +}: WorkspacesByRepoFetchInputs): UseQueryOptions { + // Disabling query when there is no query text for performance reasons; // searching through every workspace with an empty string can be incredibly // slow. - const enabled = - inputs.auth.isAuthenticated && inputs.coderQuery.trim() !== ''; + const enabled = auth.isAuthenticated && coderQuery.trim() !== ''; return { - queryKey: [ - ...getSharedWorkspacesQueryKey(inputs.coderQuery), - inputs.workspacesConfig, - ], - queryFn: () => getWorkspacesByRepo(inputs), + queryKey: [...getSharedWorkspacesQueryKey(coderQuery), workspacesConfig], enabled, keepPreviousData: enabled, refetchInterval: getCoderWorkspacesRefetchInterval, + queryFn: async () => { + const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; + const res = await coderSdk.getWorkspacesByRepo(request, workspacesConfig); + return res.workspaces; + }, }; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 745e6dc2..852abce1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -12,13 +12,12 @@ import { useQueryClient, } from '@tanstack/react-query'; import { BackstageHttpError } from '../../api/errors'; -import { getCoderApiRequestInit } from '../../api/api'; import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; -import { useUrlSync } from '../../hooks/useUrlSync'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { coderClientApiRef } from '../../api/CoderClient'; +import { useApi } from '@backstage/core-plugin-api'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -98,35 +97,22 @@ export function useCoderAuth(): CoderAuth { type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - const identityApi = useApi(identityApiRef); - const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const { api: urlSyncApi } = useUrlSync(); - // Need to split hairs, because the query object can be disabled. Only want to // expose the initializing state if the app mounts with a token already in // localStorage const [authToken, setAuthToken] = useState(readAuthToken); const [readonlyInitialAuthToken] = useState(authToken); + const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); + const coderClient = useApi(coderClientApiRef); const queryIsEnabled = authToken !== ''; + const authValidityQuery = useQuery({ queryKey: [...sharedAuthQueryKey, authToken], + queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, refetchOnWindowFocus: query => query.state.data !== false, - queryFn: async () => { - // In this case, the request doesn't actually matter. Just need to make any - // kind of dummy request to validate the auth - const requestInit = await getCoderApiRequestInit(authToken, identityApi); - const apiEndpoint = await urlSyncApi.getApiEndpoint(); - const response = await fetch(`${apiEndpoint}/users/me`, requestInit); - - if (response.status >= 400 && response.status !== 401) { - throw new BackstageHttpError('Failed to complete request', response); - } - - return response.status !== 401; - }, }); const authState = generateAuthState({ diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 1b6b87da..955aae28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -1,8 +1,8 @@ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react'; -import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; +import { TestApiProvider } from '@backstage/test-utils'; import { configApiRef, discoveryApiRef, @@ -27,6 +27,7 @@ import { renderHookAsCoderEntity, } from '../../testHelpers/setup'; import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; +import { CoderClient, coderClientApiRef } from '../../api/CoderClient'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -50,47 +51,6 @@ describe(`${CoderProvider.name}`, () => { expect(result.current).toBe(mockAppConfig); } }); - - // Our documentation pushes people to define the config outside a component, - // just to stabilize the memory reference for the value, and make sure that - // memoization caches don't get invalidated too often. This test is just a - // safety net to catch what happens if someone forgets - test('Context value will change by reference on re-render if defined inline inside a parent', () => { - const ParentComponent = ({ children }: PropsWithChildren) => { - const configThatChangesEachRender = { ...mockAppConfig }; - - const discoveryApi = getMockDiscoveryApi(); - const configApi = getMockConfigApi(); - const urlSyncApi = new UrlSync({ - apis: { discoveryApi, configApi }, - }); - - return wrapInTestApp( - - - {children} - - , - ); - }; - - const { result, rerender } = renderHook(useCoderAppConfig, { - wrapper: ParentComponent, - }); - - const firstResult = result.current; - rerender(); - - expect(result.current).not.toBe(firstResult); - expect(result.current).toEqual(firstResult); - }); }); describe('Auth', () => { @@ -100,10 +60,16 @@ describe(`${CoderProvider.name}`, () => { const renderUseCoderAuth = () => { const discoveryApi = getMockDiscoveryApi(); const configApi = getMockConfigApi(); - const urlSyncApi = new UrlSync({ + const identityApi = getMockIdentityApi(); + + const urlSync = new UrlSync({ apis: { discoveryApi, configApi }, }); + const coderClientApi = new CoderClient({ + apis: { urlSync, identityApi }, + }); + return renderHook(useCoderAuth, { wrapper: ({ children }) => ( { [identityApiRef, getMockIdentityApi()], [configApiRef, configApi], [discoveryApiRef, discoveryApi], - [urlSyncApiRef, urlSyncApi], + + [urlSyncApiRef, urlSync], + [coderClientApiRef, coderClientApi], ]} > { it('Should display a fallback UI element instead of a broken image when the image fails to load', async () => { const workspaceName = 'blah'; - const imgPath = `${mockBackstageProxyEndpoint}/wrongUrlPal.png`; + const imgPath = `${mockBackstageApiEndpoint}/wrongUrlPal.png`; await renderInCoderEnvironment({ children: ( diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 26b68daf..f7292e51 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -9,7 +9,7 @@ import { type Theme, makeStyles } from '@material-ui/core'; import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; -import { getWorkspaceAgentStatuses } from '../../api/api'; +import { getWorkspaceAgentStatuses } from '../../utils/workspaces'; import type { Workspace, WorkspaceStatus } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts new file mode 100644 index 00000000..8fbec12c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -0,0 +1,7 @@ +import { useApi } from '@backstage/core-plugin-api'; +import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; + +export function useCoderSdk(): BackstageCoderSdk { + const coderClient = useApi(coderClientApiRef); + return coderClient.sdk; +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index ea8405bd..a3b22d3d 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -1,10 +1,8 @@ import { useQuery } from '@tanstack/react-query'; - import { workspaces, workspacesByRepo } from '../api/queryOptions'; -import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider'; -import { useUrlSync } from './useUrlSync'; -import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; +import { useCoderSdk } from './useCoderSdk'; +import { useCoderAuth } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -16,19 +14,12 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useCoderAuth(); - const identityApi = useApi(identityApiRef); - const { api: urlSyncApi } = useUrlSync(); + const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ - coderQuery, - auth, - identityApi, - urlSyncApi, - workspacesConfig, - }) - : workspaces({ coderQuery, auth, identityApi, urlSyncApi }); + ? workspacesByRepo({ auth, coderSdk, coderQuery, workspacesConfig }) + : workspaces({ auth, coderSdk, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index acc5b282..164242f7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -6,13 +6,13 @@ import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import { mockBackstageAssetsEndpoint, - mockBackstageProxyEndpoint, + mockBackstageApiEndpoint, mockBackstageUrlRoot, getMockConfigApi, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageProxyEndpoint; + let proxyEndpoint: string = mockBackstageApiEndpoint; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -34,6 +34,7 @@ function renderUseUrlSync() { return { ...renderResult, + urlSync, updateMockProxyEndpoint: (newEndpoint: string) => { proxyEndpoint = newEndpoint; }, @@ -52,18 +53,19 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageProxyEndpoint, + apiRoute: mockBackstageApiEndpoint, }, }), ); }); it('Should re-render when URLs change via the UrlSync class', async () => { - const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const { result, urlSync, updateMockProxyEndpoint } = renderUseUrlSync(); const initialState = result.current.state; updateMockProxyEndpoint(altProxyUrl); - await act(() => result.current.api.getApiEndpoint()); + await act(() => urlSync.getApiEndpoint()); + const newState = result.current.state; expect(newState).not.toEqual(initialState); }); @@ -71,7 +73,7 @@ describe(`${useUrlSync.name}`, () => { describe('Render helpers', () => { it('isEmojiUrl should correctly detect whether a URL is valid', async () => { - const { result, updateMockProxyEndpoint } = renderUseUrlSync(); + const { result, urlSync, updateMockProxyEndpoint } = renderUseUrlSync(); // Test for URL that is valid and matches the URL from UrlSync const url1 = `${mockBackstageAssetsEndpoint}/emoji`; @@ -84,7 +86,7 @@ describe(`${useUrlSync.name}`, () => { // Test for URL that was valid when the React app started up, but then // UrlSync started giving out a completely different URL updateMockProxyEndpoint(altProxyUrl); - await act(() => result.current.api.getApiEndpoint()); + await act(() => urlSync.getApiEndpoint()); expect(result.current.renderHelpers.isEmojiUrl(url1)).toBe(false); }); }); diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts index 9ec95ff7..d51fb097 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.ts @@ -1,25 +1,10 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useApi } from '@backstage/core-plugin-api'; -import { - type UrlSyncSnapshot, - type UrlSync, - urlSyncApiRef, -} from '../api/UrlSync'; +import { type UrlSyncSnapshot, urlSyncApiRef } from '../api/UrlSync'; export type UseUrlSyncResult = Readonly<{ state: UrlSyncSnapshot; - /** - * @todo This is a temporary property that is being used until the - * CoderClientApi is created, and can consume the UrlSync class directly. - * - * Delete this entire property once the new class is ready. - */ - api: Readonly<{ - getApiEndpoint: UrlSync['getApiEndpoint']; - getAssetsEndpoint: UrlSync['getAssetsEndpoint']; - }>; - /** * A collection of functions that can safely be called from within a React * component's render logic to get derived values. @@ -38,11 +23,6 @@ export function useUrlSync(): UseUrlSyncResult { return { state, - api: { - getApiEndpoint: urlSyncApi.getApiEndpoint, - getAssetsEndpoint: urlSyncApi.getAssetsEndpoint, - }, - renderHelpers: { isEmojiUrl: url => { return url.startsWith(`${state.assetsRoute}/emoji`); diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index ec09da33..5dad65dc 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -4,9 +4,11 @@ import { createApiFactory, discoveryApiRef, configApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; import { UrlSync, urlSyncApiRef } from './api/UrlSync'; +import { CoderClient, coderClientApiRef } from './api/CoderClient'; export const coderPlugin = createPlugin({ id: 'coder', @@ -24,6 +26,18 @@ export const coderPlugin = createPlugin({ }); }, }), + createApiFactory({ + api: coderClientApiRef, + deps: { + urlSync: urlSyncApiRef, + identityApi: identityApiRef, + }, + factory: ({ urlSync, identityApi }) => { + return new CoderClient({ + apis: { urlSync, identityApi }, + }); + }, + }), ], }); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index fffd265c..28e258f5 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -33,6 +33,7 @@ import { defaultUrlPrefixes, urlSyncApiRef, } from '../api/UrlSync'; +import { CoderClient, coderClientApiRef } from '../api/CoderClient'; /** * This is the key that Backstage checks from the entity data to determine the @@ -71,7 +72,7 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; * The string literal expression is complicated, but hover over it to see what * the final result is. */ -export const mockBackstageProxyEndpoint = +export const mockBackstageApiEndpoint = `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; /** @@ -298,6 +299,14 @@ export function getMockApiList(): readonly ApiTuple[] { }, }); + const mockCoderClient = new CoderClient({ + initialToken: mockCoderAuthToken, + apis: { + urlSync: mockUrlSyncApi, + identityApi: mockIdentityApi, + }, + }); + return [ // APIs that Backstage ships with normally [errorApiRef, mockErrorApi], @@ -308,5 +317,6 @@ export function getMockApiList(): readonly ApiTuple[] { // Custom APIs specific to the Coder plugin [urlSyncApiRef, mockUrlSyncApi], + [coderClientApiRef, mockCoderClient], ]; } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index 6e122aad..ce63590f 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,5 +1,5 @@ import type { Workspace, WorkspaceBuildParameter } from '../typesConstants'; -import { cleanedRepoUrl } from './mockBackstageData'; +import { cleanedRepoUrl, mockBackstageApiEndpoint } from './mockBackstageData'; /** * The main mock for a workspace whose repo URL matches cleanedRepoUrl @@ -7,7 +7,7 @@ import { cleanedRepoUrl } from './mockBackstageData'; export const mockWorkspaceWithMatch: Workspace = { id: 'workspace-with-match', name: 'Test-Workspace', - template_icon: '/emojis/dog.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/dog.svg`, owner_name: 'lil brudder', latest_build: { id: 'workspace-with-match-build', @@ -30,7 +30,7 @@ export const mockWorkspaceWithMatch: Workspace = { export const mockWorkspaceWithMatch2: Workspace = { id: 'workspace-with-match-2', name: 'Another-Test', - template_icon: '/emojis/z.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/z.svg`, owner_name: 'Coach Z', latest_build: { id: 'workspace-with-match-2-build', @@ -51,7 +51,7 @@ export const mockWorkspaceWithMatch2: Workspace = { export const mockWorkspaceNoMatch: Workspace = { id: 'workspace-no-match', name: 'No-match', - template_icon: '/emojis/star.svg', + template_icon: `${mockBackstageApiEndpoint}/emojis/star.svg`, owner_name: 'homestar runner', latest_build: { id: 'workspace-no-match-build', @@ -74,7 +74,7 @@ export const mockWorkspaceNoMatch: Workspace = { export const mockWorkspaceNoParameters: Workspace = { id: 'workspace-no-parameters', name: 'No-parameters', - template_icon: '/emojis/cheese.png', + template_icon: `${mockBackstageApiEndpoint}/emojis/cheese.png`, owner_name: 'The Cheat', latest_build: { id: 'workspace-no-parameters-build', diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 71d21145..47751269 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -15,12 +15,14 @@ import { mockWorkspaceBuildParameters, } from './mockCoderAppData'; import { + mockBackstageAssetsEndpoint, mockBearerToken, mockCoderAuthToken, - mockBackstageProxyEndpoint as root, + mockBackstageApiEndpoint as root, } from './mockBackstageData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; -import { CODER_AUTH_HEADER_KEY } from '../api/api'; +import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; +import { User } from '../typesConstants'; type RestResolver = ResponseResolver< RestRequest, @@ -69,7 +71,7 @@ export function wrapInDefaultMiddleware( }, resolver); } -function wrappedGet( +export function wrappedGet( path: string, resolver: RestResolver, ): RestHandler { @@ -77,8 +79,14 @@ function wrappedGet( return rest.get(path, wrapped); } +export const mockServerEndpoints = { + workspaces: `${root}/workspaces`, + authenticatedUser: `${root}/users/me`, + workspaceBuildParameters: `${root}/workspacebuilds/:workspaceBuildId/parameters`, +} as const satisfies Record; + const mainTestHandlers: readonly RestHandler[] = [ - wrappedGet(`${root}/workspaces`, (req, res, ctx) => { + wrappedGet(mockServerEndpoints.workspaces, (req, res, ctx) => { const queryText = String(req.url.searchParams.get('q')); let returnedWorkspaces: Workspace[]; @@ -99,23 +107,27 @@ const mainTestHandlers: readonly RestHandler[] = [ ); }), - wrappedGet( - `${root}/workspacebuilds/:workspaceBuildId/parameters`, - (req, res, ctx) => { - const buildId = String(req.params.workspaceBuildId); - const selectedParams = mockWorkspaceBuildParameters[buildId]; + wrappedGet(mockServerEndpoints.workspaceBuildParameters, (req, res, ctx) => { + const buildId = String(req.params.workspaceBuildId); + const selectedParams = mockWorkspaceBuildParameters[buildId]; - if (selectedParams !== undefined) { - return res(ctx.status(200), ctx.json(selectedParams)); - } + if (selectedParams !== undefined) { + return res(ctx.status(200), ctx.json(selectedParams)); + } - return res(ctx.status(404)); - }, - ), + return res(ctx.status(404)); + }), // This is the dummy request used to verify a user's auth status - wrappedGet(`${root}/users/me`, (_, res, ctx) => { - return res(ctx.status(200)); + wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + id: '1', + avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, + username: 'blueberry', + }), + ); }), ]; diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index b92d0cdb..788a2dba 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -98,3 +98,20 @@ export type WorkspacesResponse = Output; export type WorkspaceBuildParameter = Output< typeof workspaceBuildParameterSchema >; + +export type WorkspacesRequest = Readonly<{ + after_id?: string; + limit?: number; + offset?: number; + q?: string; +}>; + +// This is actually the MinimalUser type from Coder core (User extends from +// ReducedUser, which extends from MinimalUser). Don't need all the properties +// until we roll out full SDK support, so going with the least privileged +// type definition for now +export type User = Readonly<{ + id: string; + username: string; + avatar_url: string; +}>; diff --git a/plugins/backstage-plugin-coder/src/utils/time.ts b/plugins/backstage-plugin-coder/src/utils/time.ts new file mode 100644 index 00000000..b37ce94b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/time.ts @@ -0,0 +1,9 @@ +export function delay(timeoutMs: number): Promise { + if (!Number.isInteger(timeoutMs) || timeoutMs < 0) { + throw new Error('Cannot delay by non-integer or negative values'); + } + + return new Promise(resolve => { + window.setTimeout(resolve, timeoutMs); + }); +} diff --git a/plugins/backstage-plugin-coder/src/utils/workspaces.ts b/plugins/backstage-plugin-coder/src/utils/workspaces.ts new file mode 100644 index 00000000..c36b6d4b --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/workspaces.ts @@ -0,0 +1,22 @@ +import type { Workspace, WorkspaceAgentStatus } from '../typesConstants'; + +export function getWorkspaceAgentStatuses( + workspace: Workspace, +): readonly WorkspaceAgentStatus[] { + const uniqueStatuses: WorkspaceAgentStatus[] = []; + + for (const resource of workspace.latest_build.resources) { + if (resource.agents === undefined) { + continue; + } + + for (const agent of resource.agents) { + const status = agent.status; + if (!uniqueStatuses.includes(status)) { + uniqueStatuses.push(status); + } + } + } + + return uniqueStatuses; +} diff --git a/yarn.lock b/yarn.lock index b060021e..d1df1176 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9960,6 +9960,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -13528,6 +13537,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" From cd9f90ced80ee1a31f9eb929e56b4f3add965b27 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 3 May 2024 16:28:57 -0400 Subject: [PATCH 22/33] refactor(Coder plugin): update workspace queries to use updated API endpoint definitions (#126) * wip: commit progress on UrlSync class/hook * refactor: consolidate emoji-testing logic * docs: update comments for clarity * refactor: rename helpers to renderHelpers * wip: finish initial implementation of UrlSync * chore: finish tests for UrlSync class * chore: add mock DiscoveryApi helper * chore: finish tests for useUrlSync * refactor: consolidate mock URL logic for useUrlSync * fix: update test helper to use API list * fix: remove unneeded imports * fix: get tests for all current code passing * fix: remove typo * fix: update useUrlSync to expose underlying api * refactor: increase data hiding for hook * fix: make useUrlSync tests less dependent on implementation details * refactor: remove reliance on baseUrl argument for fetch calls * refactor: split Backstage error type into separate file * refactor: clean up imports for api file * refactor: split main query options into separate file * consolidate how mock endpoints are defined * fix: remove base URL from auth calls * refactor: consolidate almost all auth logic into CoderAuthProvider * move api file into api directory * fix: revert prop that was changed for debugging * fix: revert prop definition * refactor: extract token-checking logic into middleware for server * refactor: move shared auth key to queryOptions file * docs: add reminder about arrow functions * wip: add initial versions of CoderClient code * wip: delete entire api.ts file * fix: remove temp api escape hatch for useUrlSync * chore: update syncToken logic to use temporary interceptors * refactor: update variable name for clarity * fix: prevent double-cancellation of timeout signals * fix: cleanup timeout logic * refactor: split pseudo-SDK into separate file * fix: resolve issue with conflicting interceptors * chore: improve cleanup logic * fix: update majority of breaking tests * fix: resolve all breaking tests * fix: beef up CoderClient validation logic * chore: commit first passing test for CoderClient * fix: update error-detection logic in test * wip: add all test stubs for CoderClient * chore: add test cases for syncToken's main return type * chore: add more test cases * fix: remove Object.freeze logic * refactor: consolidate mock API endpoints in one spot * wip: commit current test progress * refactor: rename mock API endpoint variable for clarity * chore: finish test for aborting queued requests * chore: finish initial versions of all CoderClient tests * fix: delete helper that was never used * fix: update getWorkspacesByRepo function signature to be more consistent with base function * docs: add comment reminder about arrow functions for CoderClient * docs: add comment explaining use of interceptor logic * fix: update return type of getWorkspacesByRepo function * fix: finish initial implementation of new API logic * wip: commit progress for updating test setup * fix: update test for CoderClient * fix: update more tests * fix: get all tests passing * chore: remove all build parameter logic * fix: add check for missing key/value for workspaces query --- .../src/api/CoderClient.test.ts | 19 ++---- .../src/api/CoderClient.ts | 65 +++++++++++++------ .../src/api/MockCoderSdk.ts | 14 ---- .../CoderWorkspacesCard.test.tsx | 12 ++-- .../src/testHelpers/mockCoderAppData.ts | 28 ++------ .../src/testHelpers/server.ts | 53 ++++++++------- .../src/typesConstants.ts | 12 ---- 7 files changed, 92 insertions(+), 111 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 945d8317..9addcd1a 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -9,7 +9,10 @@ import { rest } from 'msw'; import { mockServerEndpoints, server, wrappedGet } from '../testHelpers/server'; import { CanceledError } from 'axios'; import { delay } from '../utils/time'; -import { mockWorkspacesList } from '../testHelpers/mockCoderAppData'; +import { + mockWorkspacesList, + mockWorkspacesListForRepoSearch, +} from '../testHelpers/mockCoderAppData'; import type { Workspace, WorkspacesResponse } from '../typesConstants'; import { getMockConfigApi, @@ -197,19 +200,7 @@ describe(`${CoderClient.name}`, () => { mockCoderWorkspacesConfig, ); - const buildParameterGroups = await Promise.all( - workspaces.map(ws => - client.sdk.getWorkspaceBuildParameters(ws.latest_build.id), - ), - ); - - for (const paramGroup of buildParameterGroups) { - const atLeastOneParamMatchesForGroup = paramGroup.some(param => { - return param.value === mockCoderWorkspacesConfig.repoUrl; - }); - - expect(atLeastOneParamMatchesForGroup).toBe(true); - } + expect(workspaces).toEqual(mockWorkspacesListForRepoSearch); }); }); }); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 047c08ca..7c09f72c 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -200,37 +200,39 @@ export class CoderClient implements CoderClientApi { request: WorkspacesRequest, config: CoderWorkspacesConfig, ): Promise => { - const { workspaces } = await baseSdk.getWorkspaces(request); - const paramResults = await Promise.allSettled( - workspaces.map(ws => - this.sdk.getWorkspaceBuildParameters(ws.latest_build.id), - ), + if (config.repoUrl === undefined) { + return { workspaces: [], count: 0 }; + } + + // Have to store value here so that type information doesn't degrade + // back to (string | undefined) inside the .map callback + const stringUrl = config.repoUrl; + const responses = await Promise.allSettled( + config.repoUrlParamKeys.map(key => { + const patchedRequest = { + ...request, + q: appendParamToQuery(request.q, key, stringUrl), + }; + + return baseSdk.getWorkspaces(patchedRequest); + }), ); - const matchedWorkspaces: Workspace[] = []; - for (const [index, res] of paramResults.entries()) { + const uniqueWorkspaces = new Map(); + for (const res of responses) { if (res.status === 'rejected') { continue; } - for (const param of res.value) { - const include = - config.repoUrlParamKeys.includes(param.name) && - param.value === config.repoUrl; - - if (include) { - // Doing type assertion just in case noUncheckedIndexedAccess - // compiler setting ever gets turned on; this shouldn't ever break, - // but it's technically not type-safe - matchedWorkspaces.push(workspaces[index] as Workspace); - break; - } + for (const workspace of res.value.workspaces) { + uniqueWorkspaces.set(workspace.id, workspace); } } + const serialized = [...uniqueWorkspaces.values()]; return { - workspaces: matchedWorkspaces, - count: matchedWorkspaces.length, + workspaces: serialized, + count: serialized.length, }; }; @@ -352,6 +354,27 @@ export class CoderClient implements CoderClientApi { }; } +function appendParamToQuery( + query: string | undefined, + key: string, + value: string, +): string { + if (!key || !value) { + return ''; + } + + const keyValuePair = `param:"${key}=${value}"`; + if (!query) { + return keyValuePair; + } + + if (query.includes(keyValuePair)) { + return query; + } + + return `${query} ${keyValuePair}`; +} + function assertValidUser(value: unknown): asserts value is User { if (value === null || typeof value !== 'object') { throw new Error('Returned JSON value is not an object'); diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts index 4245a65a..3100242b 100644 --- a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts @@ -9,16 +9,12 @@ import globalAxios, { type AxiosInstance } from 'axios'; import { type User, type WorkspacesRequest, - type WorkspaceBuildParameter, type WorkspacesResponse, } from '../typesConstants'; type CoderSdkApi = { getAuthenticatedUser: () => Promise; getWorkspaces: (request: WorkspacesRequest) => Promise; - getWorkspaceBuildParameters: ( - workspaceBuildId: string, - ) => Promise; }; export class CoderSdk implements CoderSdkApi { @@ -45,16 +41,6 @@ export class CoderSdk implements CoderSdkApi { return response.data; }; - getWorkspaceBuildParameters = async ( - workspaceBuildId: string, - ): Promise => { - const response = await this.axios.get( - `/workspacebuilds/${workspaceBuildId}/parameters`, - ); - - return response.data; - }; - getAuthenticatedUser = async (): Promise => { const response = await this.axios.get('/users/me'); return response.data; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx index b99a9d69..a8cbef6c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx @@ -73,7 +73,7 @@ describe(`${CoderWorkspacesCard.name}`, () => { await user.tripleClick(searchbox); await user.keyboard('[Backspace]'); - await user.keyboard('I can do it - I can do it nine times'); + await user.keyboard('I-can-do-it-I-can-do-it-nine-times'); await waitFor(() => { // getAllByRole will throw if there isn't at least one node matched @@ -153,12 +153,12 @@ describe(`${CoderWorkspacesCard.name}`, () => { }); /** - * 2024-03-28 - MES - This is a test case to account for a previous - * limitation around querying workspaces by repo URL. + * For performance reasons, the queries for getting workspaces by repo are + * disabled when the query string is empty. * - * This limitation no longer exists, so this test should be removed once the - * rest of the codebase is updated to support the new API endpoint for - * searching by build parameter + * Even with the API endpoint for searching workspaces by build parameter, + * you still have to shoot off a bunch of requests just to find everything + * that could possibly match your Backstage deployment's config options. */ it('Will not show any workspaces at all when the query text is empty', async () => { await renderWorkspacesCard({ readEntityData: true }); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index ce63590f..412e0e05 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,5 +1,5 @@ -import type { Workspace, WorkspaceBuildParameter } from '../typesConstants'; -import { cleanedRepoUrl, mockBackstageApiEndpoint } from './mockBackstageData'; +import type { Workspace } from '../typesConstants'; +import { mockBackstageApiEndpoint } from './mockBackstageData'; /** * The main mock for a workspace whose repo URL matches cleanedRepoUrl @@ -98,23 +98,7 @@ export const mockWorkspacesList: Workspace[] = [ mockWorkspaceNoParameters, ]; -export const mockWorkspaceBuildParameters: Record< - string, - readonly WorkspaceBuildParameter[] -> = { - [mockWorkspaceWithMatch.latest_build.id]: [ - { name: 'repo_url', value: cleanedRepoUrl }, - ], - - [mockWorkspaceWithMatch2.latest_build.id]: [ - { name: 'repo_url', value: cleanedRepoUrl }, - ], - - [mockWorkspaceNoMatch.latest_build.id]: [ - { name: 'repo_url', value: 'https://www.github.com/wombo/zom' }, - ], - - [mockWorkspaceNoParameters.latest_build.id]: [ - // Intentionally kept empty - ], -}; +export const mockWorkspacesListForRepoSearch: Workspace[] = [ + mockWorkspaceWithMatch, + mockWorkspaceWithMatch2, +]; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 47751269..69fe816a 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -12,15 +12,16 @@ import { setupServer } from 'msw/node'; import { mockWorkspacesList, - mockWorkspaceBuildParameters, + mockWorkspacesListForRepoSearch, } from './mockCoderAppData'; import { mockBackstageAssetsEndpoint, mockBearerToken, mockCoderAuthToken, + mockCoderWorkspacesConfig, mockBackstageApiEndpoint as root, } from './mockBackstageData'; -import type { Workspace, WorkspacesResponse } from '../typesConstants'; +import type { WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; import { User } from '../typesConstants'; @@ -87,37 +88,45 @@ export const mockServerEndpoints = { const mainTestHandlers: readonly RestHandler[] = [ wrappedGet(mockServerEndpoints.workspaces, (req, res, ctx) => { - const queryText = String(req.url.searchParams.get('q')); + const { repoUrl } = mockCoderWorkspacesConfig; + const paramMatcherRe = new RegExp( + `param:"\\w+?=${repoUrl.replace('/', '\\/')}"`, + ); - let returnedWorkspaces: Workspace[]; - if (queryText === 'owner:me') { - returnedWorkspaces = mockWorkspacesList; - } else { - returnedWorkspaces = mockWorkspacesList.filter(ws => - ws.name.includes(queryText), + const queryText = String(req.url.searchParams.get('q')); + const requestContainsRepoInfo = paramMatcherRe.test(queryText); + + const baseWorkspaces = requestContainsRepoInfo + ? mockWorkspacesListForRepoSearch + : mockWorkspacesList; + + const customSearchTerms = queryText + .split(' ') + .filter(text => text !== 'owner:me' && !paramMatcherRe.test(text)); + + if (customSearchTerms.length === 0) { + return res( + ctx.status(200), + ctx.json({ + workspaces: baseWorkspaces, + count: baseWorkspaces.length, + }), ); } + const filtered = mockWorkspacesList.filter(ws => { + return customSearchTerms.some(term => ws.name.includes(term)); + }); + return res( ctx.status(200), ctx.json({ - workspaces: returnedWorkspaces, - count: returnedWorkspaces.length, + workspaces: filtered, + count: filtered.length, }), ); }), - wrappedGet(mockServerEndpoints.workspaceBuildParameters, (req, res, ctx) => { - const buildId = String(req.params.workspaceBuildId); - const selectedParams = mockWorkspaceBuildParameters[buildId]; - - if (selectedParams !== undefined) { - return res(ctx.status(200), ctx.json(selectedParams)); - } - - return res(ctx.status(404)); - }), - // This is the dummy request used to verify a user's auth status wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => { return res( diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index 788a2dba..d9922920 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -74,15 +74,6 @@ export const workspaceSchema = object({ latest_build: workspaceBuildSchema, }); -export const workspaceBuildParameterSchema = object({ - name: string(), - value: string(), -}); - -export const workspaceBuildParametersSchema = array( - workspaceBuildParameterSchema, -); - export const workspacesResponseSchema = object({ count: number(), workspaces: array(workspaceSchema), @@ -95,9 +86,6 @@ export type WorkspaceStatus = Output; export type WorkspaceBuild = Output; export type Workspace = Output; export type WorkspacesResponse = Output; -export type WorkspaceBuildParameter = Output< - typeof workspaceBuildParameterSchema ->; export type WorkspacesRequest = Readonly<{ after_id?: string; From a4f3749731c36e764f0ae1f7bf7db76af72ad5c6 Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 14 May 2024 10:05:15 -0800 Subject: [PATCH 23/33] Fix root lint command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c6b0c89b..74a5bfa8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:all": "backstage-cli repo test --coverage", "test:e2e": "playwright test", "fix": "backstage-cli repo fix", - "lint": "backstage-cli repo lint --since origin/master", + "lint": "backstage-cli repo lint --since origin/main", "lint:all": "backstage-cli repo lint", "prettier:check": "prettier --check .", "new": "backstage-cli new --scope internal" From 2fd095994cec2999f653bd4a30630f3329c2b99a Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 14 May 2024 19:12:58 +0000 Subject: [PATCH 24/33] Parse git urls (#127) --- .../package.json | 2 + .../src/index.ts | 5 +- .../processors/DevcontainersProcessor.test.ts | 10 +++ .../src/processors/DevcontainersProcessor.ts | 40 +++++++++++- .../src/utils/git.test.ts | 64 +++++++++++++++++++ .../src/utils/git.ts | 12 ++++ .../README.md | 4 +- .../ExampleDevcontainersComponent.tsx | 2 +- .../src/hooks/useDevcontainers.test.tsx | 7 +- .../src/hooks/useDevcontainers.ts | 27 +++----- yarn.lock | 36 ++++++++++- 11 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 plugins/backstage-plugin-devcontainers-backend/src/utils/git.test.ts create mode 100644 plugins/backstage-plugin-devcontainers-backend/src/utils/git.ts diff --git a/plugins/backstage-plugin-devcontainers-backend/package.json b/plugins/backstage-plugin-devcontainers-backend/package.json index 57867586..5fde7234 100644 --- a/plugins/backstage-plugin-devcontainers-backend/package.json +++ b/plugins/backstage-plugin-devcontainers-backend/package.json @@ -33,11 +33,13 @@ "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "git-url-parse": "^14.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { "@backstage/cli": "^0.25.1", + "@types/git-url-parse": "^9.0.3", "@types/supertest": "^2.0.12", "msw": "^1.0.0", "supertest": "^6.2.4" diff --git a/plugins/backstage-plugin-devcontainers-backend/src/index.ts b/plugins/backstage-plugin-devcontainers-backend/src/index.ts index 1155207e..47c37ec2 100644 --- a/plugins/backstage-plugin-devcontainers-backend/src/index.ts +++ b/plugins/backstage-plugin-devcontainers-backend/src/index.ts @@ -1,2 +1,5 @@ export * from './service/router'; -export { DevcontainersProcessor } from './processors/DevcontainersProcessor'; +export { + DevcontainersProcessor, + type VsCodeUrlKey, +} from './processors/DevcontainersProcessor'; diff --git a/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.test.ts b/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.test.ts index 7e454f31..2eabf6b7 100644 --- a/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.test.ts +++ b/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.test.ts @@ -196,6 +196,11 @@ describe(`${DevcontainersProcessor.name}`, () => { expect(inputEntity).toEqual(inputSnapshot); const metadataCompare = structuredClone(inputSnapshot.metadata); + metadataCompare.annotations = { + ...(metadataCompare.annotations ?? {}), + vsCodeUrl: + 'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo', + }; delete metadataCompare.tags; expect(outputEntity).toEqual( @@ -226,6 +231,11 @@ describe(`${DevcontainersProcessor.name}`, () => { expect(inputEntity).toEqual(inputSnapshot); const metadataCompare = structuredClone(inputSnapshot.metadata); + metadataCompare.annotations = { + ...(metadataCompare.annotations ?? {}), + vsCodeUrl: + 'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo', + }; delete metadataCompare.tags; expect(outputEntity).toEqual( diff --git a/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.ts b/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.ts index 9a2f2732..a42f5ba5 100644 --- a/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.ts +++ b/plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.ts @@ -9,10 +9,18 @@ import { type Config } from '@backstage/config'; import { isError, NotFoundError } from '@backstage/errors'; import { type UrlReader, UrlReaders } from '@backstage/backend-common'; import { type Logger } from 'winston'; +import { parseGitUrl } from '../utils/git'; export const DEFAULT_TAG_NAME = 'devcontainers'; export const PROCESSOR_NAME_PREFIX = 'backstage-plugin-devcontainers-backend'; +const vsCodeUrlKey = 'vsCodeUrl'; + +// We export this type instead of the actual constant so we can validate the +// constant on the frontend at compile-time instead of making the backend plugin +// a run-time dependency, so it can continue to run standalone. +export type VsCodeUrlKey = typeof vsCodeUrlKey; + type ProcessorOptions = Readonly<{ tagName: string; logger: Logger; @@ -89,7 +97,12 @@ export class DevcontainersProcessor implements CatalogProcessor { try { const jsonUrl = await this.findDevcontainerJson(rootUrl, entityLogger); entityLogger.info('Found devcontainer config', { url: jsonUrl }); - return this.addTag(entity, this.options.tagName, entityLogger); + return this.addMetadata( + entity, + this.options.tagName, + location, + entityLogger, + ); } catch (error) { if (!isError(error) || error.name !== 'NotFoundError') { emit( @@ -115,16 +128,25 @@ export class DevcontainersProcessor implements CatalogProcessor { return entity; } - private addTag(entity: Entity, newTag: string, logger: Logger): Entity { + private addMetadata( + entity: Entity, + newTag: string, + location: LocationSpec, + logger: Logger, + ): Entity { if (entity.metadata.tags?.includes(newTag)) { return entity; } - logger.info(`Adding "${newTag}" tag to component`); + logger.info(`Adding VS Code URL and "${newTag}" tag to component`); return { ...entity, metadata: { ...entity.metadata, + annotations: { + ...(entity.metadata.annotations ?? {}), + [vsCodeUrlKey]: serializeVsCodeUrl(location.target), + }, tags: [...(entity.metadata?.tags ?? []), newTag], }, }; @@ -185,3 +207,15 @@ export class DevcontainersProcessor implements CatalogProcessor { return url; } } + +/** + * Current implementation for generating the URL will likely need to change as + * we flesh out the backend plugin. For example, it would be nice if there was + * a way to specify the branch instead of always checking out the default. + */ +function serializeVsCodeUrl(repoUrl: string): string { + const cleaners: readonly RegExp[] = [/^url: */]; + const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl); + const rootUrl = parseGitUrl(cleanedUrl); + return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${rootUrl}`; +} diff --git a/plugins/backstage-plugin-devcontainers-backend/src/utils/git.test.ts b/plugins/backstage-plugin-devcontainers-backend/src/utils/git.test.ts new file mode 100644 index 00000000..cc8a2450 --- /dev/null +++ b/plugins/backstage-plugin-devcontainers-backend/src/utils/git.test.ts @@ -0,0 +1,64 @@ +import { parseGitUrl } from './git'; + +describe('git', () => { + it('parses urls', () => { + // List of forges and the various ways URLs can be formed. + const forges = { + github: { + saas: 'github.com', + paths: [ + '/tree/foo', + '/blob/foo', + '/tree/foo/dir', + '/blob/foo/dir/file.ts', + ], + }, + gitlab: { + saas: 'gitlab.com', + paths: [ + '/-/tree/foo', + '/-/blob/foo', + '/-/tree/foo/dir?ref_type=heads', + '/-/blob/foo/dir/file.ts?ref_type=heads', + ], + }, + bitbucket: { + saas: 'bitbucket.org', + paths: [ + '/src/hashOrTag', + '/src/hashOrTag?at=foo', + '/src/hashOrTag/dir', + '/src/hashOrTag/dir?at=foo', + '/src/hashOrTag/dir/file.ts', + '/src/hashOrTag/dir/file.ts?at=foo', + ], + }, + }; + + for (const [forge, test] of Object.entries(forges)) { + // These are URLs that point to the root of the repository. To these we + // append the above paths to test that the original root URL is extracted. + const baseUrls = [ + // Most common format. + `https://${test.saas}/coder/backstage-plugins`, + // GitLab lets you have a sub-group. + `https://${test.saas}/coder/group/backstage-plugins`, + // Self-hosted. + `https://${forge}.coder.com/coder/backstage-plugins`, + // Self-hosted at a port. + `https://${forge}.coder.com:9999/coder/backstage-plugins`, + // Self-hosted at base path. + `https://${forge}.coder.com/base/path/coder/backstage-plugins`, + // Self-hosted without the forge anywhere in the domain. + 'https://coder.com/coder/backstage-plugins', + ]; + for (const baseUrl of baseUrls) { + expect(parseGitUrl(baseUrl)).toEqual(baseUrl); + for (const path of test.paths) { + const url = `${baseUrl}${path}`; + expect(parseGitUrl(url)).toEqual(baseUrl); + } + } + } + }); +}); diff --git a/plugins/backstage-plugin-devcontainers-backend/src/utils/git.ts b/plugins/backstage-plugin-devcontainers-backend/src/utils/git.ts new file mode 100644 index 00000000..68a554bd --- /dev/null +++ b/plugins/backstage-plugin-devcontainers-backend/src/utils/git.ts @@ -0,0 +1,12 @@ +import parse from 'git-url-parse'; + +/** + * Given a repository URL, figure out the base repository. + */ +export function parseGitUrl(url: string): String { + const parsed = parse(url); + // Although it seems to have a `host` property, it is not on the types, so we + // will have to reconstruct it. + const host = parsed.resource + (parsed.port ? `:${parsed.port}` : ''); + return `${parsed.protocol}://${host}/${parsed.full_name}`; +} diff --git a/plugins/backstage-plugin-devcontainers-react/README.md b/plugins/backstage-plugin-devcontainers-react/README.md index b35786b4..2e14637c 100644 --- a/plugins/backstage-plugin-devcontainers-react/README.md +++ b/plugins/backstage-plugin-devcontainers-react/README.md @@ -14,11 +14,11 @@ _Note: While this plugin can be used standalone, it has been designed to be a fr ### Standalone features -- Custom hooks for reading your special Dev Container metadata tag inside your repo entities, and providing ready-made links to opening that repo in VS Code +- Custom hooks for reading your special Dev Container metadata tag and VS Code launch URI inside your repo entities, and exposing that URI for opening the repo in VS Code ### When combined with the backend plugin -- Provides an end-to-end solution for automatically adding/removing Dev Containers metadata in your Backstage installation, while letting you read them from custom hooks and components +- Provides an end-to-end solution for automatically adding/removing Dev Containers metadata in your Backstage installation (including tags and the VS Code launch URI), while letting you read them from custom hooks and components ## Setup diff --git a/plugins/backstage-plugin-devcontainers-react/src/components/ExampleDevcontainersComponent/ExampleDevcontainersComponent.tsx b/plugins/backstage-plugin-devcontainers-react/src/components/ExampleDevcontainersComponent/ExampleDevcontainersComponent.tsx index c9a24846..53e96b80 100644 --- a/plugins/backstage-plugin-devcontainers-react/src/components/ExampleDevcontainersComponent/ExampleDevcontainersComponent.tsx +++ b/plugins/backstage-plugin-devcontainers-react/src/components/ExampleDevcontainersComponent/ExampleDevcontainersComponent.tsx @@ -23,7 +23,7 @@ export const ExampleDevcontainersComponent = () => { return (

    - Searched component entity for tag:{' '} + Searched component entity for VS Code URL and tag:{' '} {state.tagName}

    diff --git a/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.test.tsx b/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.test.tsx index 14f82354..e9999871 100644 --- a/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.test.tsx +++ b/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.test.tsx @@ -4,7 +4,6 @@ import { useDevcontainers } from './useDevcontainers'; import { type DevcontainersConfig, DevcontainersProvider } from '../plugin'; import { wrapInTestApp } from '@backstage/test-utils'; import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react'; -import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model'; const mockTagName = 'devcontainers-test'; const mockUrlRoot = 'https://www.github.com/example-company/example-repo'; @@ -17,7 +16,7 @@ const baseEntity: BackstageEntity = { name: 'metadata', tags: [mockTagName, 'other', 'random', 'values'], annotations: { - [ANNOTATION_SOURCE_LOCATION]: `${mockUrlRoot}/tree/main`, + vsCodeUrl: `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`, }, }, }; @@ -61,7 +60,7 @@ describe(`${useDevcontainers.name}`, () => { expect(result2.current.vsCodeUrl).toBe(undefined); }); - it('Does not expose a link when the entity lacks a repo URL', async () => { + it('Does not expose a link when the entity lacks one', async () => { const { result } = await render(mockTagName, { ...baseEntity, metadata: { @@ -73,7 +72,7 @@ describe(`${useDevcontainers.name}`, () => { expect(result.current.vsCodeUrl).toBe(undefined); }); - it('Provides a VS Code-formatted link when the current entity has a designated devcontainers tag', async () => { + it('Exposes the link when the entity has both the tag and link', async () => { const { result } = await render(mockTagName, baseEntity); expect(result.current.vsCodeUrl).toEqual( `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`, diff --git a/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.ts b/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.ts index e1b360c1..67a067b6 100644 --- a/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.ts +++ b/plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.ts @@ -1,6 +1,11 @@ import { useDevcontainersConfig } from '../components/DevcontainersProvider'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model'; +import type { VsCodeUrlKey } from '@coder/backstage-plugin-devcontainers-backend'; + +// We avoid importing the actual constant to prevent making the backend plugin a +// run-time dependency, but we can use the type at compile-time to validate the +// string is the same. +const vsCodeUrlKey: VsCodeUrlKey = 'vsCodeUrl'; export type UseDevcontainersResult = Readonly< { @@ -38,8 +43,8 @@ export function useDevcontainers(): UseDevcontainersResult { }; } - const repoUrl = entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION]; - if (!repoUrl) { + const vsCodeUrl = entity.metadata.annotations?.[vsCodeUrlKey]; + if (!vsCodeUrl) { return { tagName, hasUrl: false, @@ -50,20 +55,6 @@ export function useDevcontainers(): UseDevcontainersResult { return { tagName, hasUrl: true, - vsCodeUrl: serializeVsCodeUrl(repoUrl), + vsCodeUrl, }; } - -/** - * Current implementation for generating the URL will likely need to change as - * we flesh out the backend plugin. - * - * It might make more sense to add the direct VSCode link to the entity data - * from the backend plugin via an annotation field, and remove the need for data - * cleaning here in this function - */ -function serializeVsCodeUrl(repoUrl: string): string { - const cleaners: readonly RegExp[] = [/^url: */, /\/tree\/main\/?$/]; - const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl); - return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${cleanedUrl}`; -} diff --git a/yarn.lock b/yarn.lock index d1df1176..b13b38c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8419,6 +8419,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/git-url-parse@^9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.3.tgz#7ee022f8fa06ea74148aa28521cbff85915ac09d" + integrity sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -21913,7 +21918,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21987,7 +22001,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22001,6 +22015,13 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23809,7 +23830,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23827,6 +23848,15 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From c116ebc726fecae79ad8550f189c1d9ff681bb38 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 24 May 2024 09:32:36 -0400 Subject: [PATCH 25/33] feat: add auth fallback logic for when official Coder components are not mounted (#128) * wip: commit progress on fallback UI * chore: move dep to peer dependencies * wip: commit more progress * wip: more progress * refactor: consolidate card logic * fix: update component tracking hooks * fix: add a11y landmark to auth fallback * wip: commit more style progress * wip: commit more progress * wip: more progress * wip: cleanup current approach * wip: commit progress on observer approach * wip: fix infinite loop for mutation logic * fix: prevent padding patches from firing too often * fix: improve scoping of style overrides * chore: finish intial version of fallback stylling * fix: tidy up types * wip: create initial version of dialog form * wip: commit progress on modal * chore: finish styling for modal wrapper * fix: update padding for FormDialog * wip: start extracting out auth form * fix: add missing barrel export file * fix: make sure that auth form isn't dismissed early * fix: update auth imports * fix: update spacing for auth modal * refactor: clean up auth provider for clarity * docs: rewrite comment for clarity * fix: improve granularity between official Coder components and user components * fix: update all internal consumers of useCoderAuth * wip: commit initial version of useCoderQuery helper hook * refactor: rename hooks to avoid confusion * fix: update exports for plugin * docs: fill in incomplete sentence * wip: commit initial version of useMutation wrapper * refactor: extract retry factor into global constant * fix: add explicit return type to useCoderMutation * wip: start extracting auth logic into better reusable components * fix: update card to have better styling for body * wip: commit progress on style refactoring * fix: update vertical padding for card wrapper * chore: delete CoderAuthWrapper component * fix: update styling for auth fallback * chore: shrink size of PR * fix: update imports * docs: add comment about description setup * fix: remove risk of runtime render errors in auth form * fix: update imports * fix: update font sizes to use relative units * fix: update peer dependencies for react-dom * refactor: clean up auth revalidation logic * wip: start updating tests for new code changes * fix: adding missing test case for auth card * wip: commit progress on auth form test updates * fix: removal vetigal properties * fix: get all CoderAuthForm tests passing * fix: update import for auth hook in test --- plugins/backstage-plugin-coder/package.json | 3 +- .../components/A11yInfoCard/A11yInfoCard.tsx | 63 +++ .../src/components/A11yInfoCard/index.ts | 1 + .../src/components/Card/Card.tsx | 27 -- .../src/components/Card/index.ts | 1 - .../CoderAuthDistrustedForm.tsx | 18 +- .../CoderAuthForm.test.tsx} | 102 +--- .../CoderAuthForm.tsx} | 68 +-- .../CoderAuthInputForm.tsx | 4 +- .../CoderAuthLoadingState.tsx | 0 .../CoderAuthForm/CoderAuthSuccessStatus.tsx | 61 +++ .../CoderAuthForm/UnlinkAccountButton.tsx | 42 ++ .../src/components/CoderAuthForm/index.ts | 1 + .../CoderAuthFormCardWrapper.test.tsx | 107 +++++ .../CoderAuthFormCardWrapper.tsx | 46 ++ .../CoderAuthFormCardWrapper/index.ts | 1 + .../CoderAuthFormDialog.tsx | 145 ++++++ .../components/CoderAuthFormDialog/index.ts | 1 + .../src/components/CoderAuthWrapper/index.ts | 1 - .../CoderProvider/CoderAuthProvider.tsx | 436 +++++++++++++++--- .../CoderProvider/CoderProvider.test.tsx | 6 +- .../CoderWorkspacesCard.tsx | 25 +- .../ExtraActionsButton.tsx | 4 +- .../CoderWorkspacesCard/HeaderRow.tsx | 32 +- .../components/CoderWorkspacesCard/Root.tsx | 78 ++-- .../src/hooks/useCoderWorkspacesQuery.ts | 4 +- plugins/backstage-plugin-coder/src/plugin.ts | 21 +- .../src/testHelpers/mockBackstageData.ts | 2 - .../src/testHelpers/setup.tsx | 16 +- .../src/typesConstants.ts | 8 + yarn.lock | 61 +-- 31 files changed, 1002 insertions(+), 383 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts delete mode 100644 plugins/backstage-plugin-coder/src/components/Card/Card.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/Card/index.ts rename plugins/backstage-plugin-coder/src/components/{CoderAuthWrapper => CoderAuthForm}/CoderAuthDistrustedForm.tsx (69%) rename plugins/backstage-plugin-coder/src/components/{CoderAuthWrapper/CoderAuthWrapper.test.tsx => CoderAuthForm/CoderAuthForm.test.tsx} (56%) rename plugins/backstage-plugin-coder/src/components/{CoderAuthWrapper/CoderAuthWrapper.tsx => CoderAuthForm/CoderAuthForm.tsx} (53%) rename plugins/backstage-plugin-coder/src/components/{CoderAuthWrapper => CoderAuthForm}/CoderAuthInputForm.tsx (98%) rename plugins/backstage-plugin-coder/src/components/{CoderAuthWrapper => CoderAuthForm}/CoderAuthLoadingState.tsx (100%) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 6dcc24a8..e21caf74 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -46,7 +46,8 @@ "valibot": "^0.28.1" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { "@backstage/cli": "^0.25.1", diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx new file mode 100644 index 00000000..4c5959b9 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx @@ -0,0 +1,63 @@ +/** + * @file A slightly different take on Backstage's official InfoCard component, + * with better support for accessibility. + * + * Does not support all of InfoCard's properties just yet. + */ +import React, { type HTMLAttributes, type ReactNode, forwardRef } from 'react'; +import { makeStyles } from '@material-ui/core'; + +export type A11yInfoCardProps = Readonly< + HTMLAttributes & { + headerContent?: ReactNode; + } +>; + +const useStyles = makeStyles(theme => ({ + root: { + color: theme.palette.type, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + }, + + headerContent: { + // Ideally wouldn't be using hard-coded font sizes, but couldn't figure out + // how to use the theme.typography property, especially since not all + // sub-properties have font sizes defined + fontSize: '1.5rem', + color: theme.palette.text.primary, + fontWeight: 700, + borderBottom: `1px solid ${theme.palette.divider}`, + + // Margins and padding are a bit wonky to support full-bleed layouts + marginLeft: `-${theme.spacing(2)}px`, + marginRight: `-${theme.spacing(2)}px`, + padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px`, + }, +})); + +// Card should be treated as equivalent to Backstage's official InfoCard +// component; had to make custom version so that it could forward properties for +// accessibility/screen reader support +export const A11yInfoCard = forwardRef( + (props, ref) => { + const { className, children, headerContent, ...delegatedProps } = props; + const styles = useStyles(); + + return ( +
    + {headerContent !== undefined && ( +
    {headerContent}
    + )} + + {children} +
    + ); + }, +); diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts new file mode 100644 index 00000000..5ef69f03 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts @@ -0,0 +1 @@ +export * from './A11yInfoCard'; diff --git a/plugins/backstage-plugin-coder/src/components/Card/Card.tsx b/plugins/backstage-plugin-coder/src/components/Card/Card.tsx deleted file mode 100644 index 995b8e5c..00000000 --- a/plugins/backstage-plugin-coder/src/components/Card/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { type HTMLAttributes, forwardRef } from 'react'; -import { makeStyles } from '@material-ui/core'; - -const useStyles = makeStyles(theme => ({ - root: { - color: theme.palette.type, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shadows[1], - }, -})); - -type CardProps = HTMLAttributes; - -export const Card = forwardRef((props, ref) => { - const { className, ...delegatedProps } = props; - const styles = useStyles(); - - return ( -
    - ); -}); diff --git a/plugins/backstage-plugin-coder/src/components/Card/index.ts b/plugins/backstage-plugin-coder/src/components/Card/index.ts deleted file mode 100644 index ca0b0604..00000000 --- a/plugins/backstage-plugin-coder/src/components/Card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Card'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx similarity index 69% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index 1a63a24a..a37c1916 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; -import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuth } from '../CoderProvider'; +import { UnlinkAccountButton } from './UnlinkAccountButton'; const useStyles = makeStyles(theme => ({ root: { @@ -31,8 +30,6 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuth(); - return (
    @@ -43,18 +40,7 @@ export const CoderAuthDistrustedForm = () => {

    - - Eject token - +
    ); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx similarity index 56% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index 43199c04..95ce2993 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -8,18 +8,14 @@ import { mockAuthStates, mockCoderAuthToken, } from '../../testHelpers/mockBackstageData'; -import { CoderAuthWrapper } from './CoderAuthWrapper'; +import { CoderAuthForm } from './CoderAuthForm'; import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ authStatus: CoderAuthStatus; - childButtonText?: string; }>; -async function renderAuthWrapper({ - authStatus, - childButtonText = 'Default button text', -}: RenderInputs) { +async function renderAuthWrapper({ authStatus }: RenderInputs) { const ejectToken = jest.fn(); const registerNewToken = jest.fn(); @@ -40,50 +36,24 @@ async function renderAuthWrapper({ */ const renderOutput = await renderInTestApp( - - - + , ); return { ...renderOutput, ejectToken, registerNewToken }; } -describe(`${CoderAuthWrapper.name}`, () => { - describe('Displaying main content', () => { - it('Displays the main children when the user is authenticated', async () => { - const buttonText = 'I have secret Coder content!'; - renderAuthWrapper({ - authStatus: 'authenticated', - childButtonText: buttonText, - }); - - const button = await screen.findByRole('button', { name: buttonText }); - - // This assertion isn't necessary because findByRole will throw an error - // if the button can't be found within the expected period of time. Doing - // this purely to make the Backstage linter happy - expect(button).toBeInTheDocument(); - }); - }); - +describe(`${CoderAuthForm.name}`, () => { describe('Loading UI', () => { it('Is displayed while the auth is initializing', async () => { - const buttonText = "You shouldn't be able to see me!"; - renderAuthWrapper({ - authStatus: 'initializing', - childButtonText: buttonText, - }); - - await screen.findByText(/Loading/); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); + renderAuthWrapper({ authStatus: 'initializing' }); + const loadingIndicator = await screen.findByText(/Loading/); + expect(loadingIndicator).toBeInTheDocument(); }); }); describe('Token distrusted form', () => { it("Is displayed when the user's auth status cannot be verified", async () => { - const buttonText = 'Not sure if you should be able to see me'; const distrustedTextMatcher = /Unable to verify token authenticity/; const distrustedStatuses: readonly CoderAuthStatus[] = [ 'distrusted', @@ -91,16 +61,11 @@ describe(`${CoderAuthWrapper.name}`, () => { 'deploymentUnavailable', ]; - for (const status of distrustedStatuses) { - const { unmount } = await renderAuthWrapper({ - authStatus: status, - childButtonText: buttonText, - }); - - await screen.findByText(distrustedTextMatcher); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); + for (const authStatus of distrustedStatuses) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const message = await screen.findByText(distrustedTextMatcher); + expect(message).toBeInTheDocument(); unmount(); } }); @@ -112,58 +77,28 @@ describe(`${CoderAuthWrapper.name}`, () => { const user = userEvent.setup(); const ejectButton = await screen.findByRole('button', { - name: 'Eject token', + name: /Unlink Coder account/, }); await user.click(ejectButton); expect(ejectToken).toHaveBeenCalled(); }); - - it('Will appear if auth status changes during re-renders', async () => { - const buttonText = "Now you see me, now you don't"; - const { rerender } = await renderAuthWrapper({ - authStatus: 'authenticated', - childButtonText: buttonText, - }); - - // Capture button after it first appears on the screen - const button = await screen.findByRole('button', { name: buttonText }); - - rerender( - - - - - , - ); - - // Assert that the button is now gone - expect(button).not.toBeInTheDocument(); - }); }); describe('Token submission form', () => { it("Is displayed when the token either doesn't exist or is definitely not valid", async () => { - const buttonText = "You're not allowed to gaze upon my visage"; - const tokenFormMatcher = /Please enter a new token/; const statusesForInvalidUser: readonly CoderAuthStatus[] = [ 'invalid', 'tokenMissing', ]; - for (const status of statusesForInvalidUser) { - const { unmount } = await renderAuthWrapper({ - authStatus: status, - childButtonText: buttonText, + for (const authStatus of statusesForInvalidUser) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const form = screen.getByRole('form', { + name: /Authenticate with Coder/, }); - await screen.findByText(tokenFormMatcher); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); - + expect(form).toBeInTheDocument(); unmount(); } }); @@ -178,7 +113,8 @@ describe(`${CoderAuthWrapper.name}`, () => { * 1. The auth input is of type password, which does not have a role * compatible with Testing Library; can't use getByRole to select it * 2. MUI adds a star to its labels that are required, meaning that any - * attempts at trying to match the string "Auth token" will fail + * attempts at trying to match string literal "Auth token" will fail; + * have to use a regex selector */ const inputField = screen.getByLabelText(/Auth token/); const submitButton = screen.getByRole('button', { name: 'Authenticate' }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx similarity index 53% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index b0e6ee22..638a1a75 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -1,52 +1,30 @@ -import React, { type FC, type PropsWithChildren } from 'react'; -import { useCoderAuth } from '../CoderProvider'; -import { InfoCard } from '@backstage/core-components'; +import React from 'react'; +import { useInternalCoderAuth } from '../CoderProvider'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; -import { makeStyles } from '@material-ui/core'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; +import { CoderAuthSuccessStatus } from './CoderAuthSuccessStatus'; -const useStyles = makeStyles(theme => ({ - cardContent: { - paddingTop: theme.spacing(5), - paddingBottom: theme.spacing(5), - }, -})); +export type CoderAuthFormProps = Readonly<{ + descriptionId?: string; +}>; -function CoderAuthCard({ children }: PropsWithChildren) { - const styles = useStyles(); - return ( - -
    {children}
    -
    - ); -} - -type WrapperProps = Readonly< - PropsWithChildren<{ - type: 'card'; - }> ->; - -export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { - const auth = useCoderAuth(); - if (auth.isAuthenticated) { - return <>{children}; - } - - let Wrapper: FC>; - switch (type) { - case 'card': { - Wrapper = CoderAuthCard; - break; - } - default: { - assertExhaustion(type); - } - } +export const CoderAuthForm = ({ descriptionId }: CoderAuthFormProps) => { + const auth = useInternalCoderAuth(); return ( - + <> + {/* + * By default this text will be inert, and not be exposed anywhere + * (Sighted and blind users won't be able to interact with it). To enable + * it for screen readers, a consuming component will need bind an ID to + * another component via aria-describedby and then pass the same ID down + * as props. + */} + + {/* Slightly awkward syntax with the IIFE, but need something switch-like to make sure that all status cases are handled exhaustively */} {(() => { @@ -69,9 +47,7 @@ export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { case 'authenticated': case 'distrustedWithGracePeriod': { - throw new Error( - 'Tried to process authenticated user after main content should already be shown', - ); + return ; } default: { @@ -79,7 +55,7 @@ export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { } } })()} - + ); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx similarity index 98% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx index f7e926b2..ae527e28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx @@ -3,7 +3,7 @@ import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, useCoderAppConfig, - useCoderAuth, + useInternalCoderAuth, } from '../CoderProvider'; import { CoderLogo } from '../CoderLogo'; @@ -49,7 +49,7 @@ export const CoderAuthInputForm = () => { const hookId = useId(); const styles = useStyles(); const appConfig = useCoderAppConfig(); - const { status, registerNewToken } = useCoderAuth(); + const { status, registerNewToken } = useInternalCoderAuth(); const onSubmit = (event: FormEvent) => { event.preventDefault(); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx similarity index 100% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx new file mode 100644 index 00000000..d2c71513 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx @@ -0,0 +1,61 @@ +/** + * @file In practice, this is a component that ideally shouldn't ever be seen by + * the end user. Any component rendering out CoderAuthForm should ideally be set + * up so that when a user is authenticated, the entire component will be + * unmounted before CoderAuthForm has a chance to handle successful states. + * + * But just for the sake of completion (and to remove the risk of runtime render + * errors), this component has been added to provide a form of double + * book-keeping for the auth status switch checks in the parent component. Don't + * want the entire plugin to blow up if an auth conditional in a different + * component is accidentally set up wrong. + */ +import React from 'react'; +import { makeStyles } from '@material-ui/core'; +import { CoderLogo } from '../CoderLogo'; +import { UnlinkAccountButton } from './UnlinkAccountButton'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + rowGap: theme.spacing(1), + + maxWidth: '30em', + marginLeft: 'auto', + marginRight: 'auto', + color: theme.palette.text.primary, + fontSize: '1rem', + }, + + statusArea: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + }, + + logo: { + // + }, + + text: { + textAlign: 'center', + lineHeight: '1rem', + }, +})); + +export function CoderAuthSuccessStatus() { + const styles = useStyles(); + + return ( +
    +
    + +

    You are fully authenticated with Coder!

    +
    + + +
    + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx new file mode 100644 index 00000000..63b9fdd0 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx @@ -0,0 +1,42 @@ +import React, { type ComponentProps } from 'react'; +import { LinkButton } from '@backstage/core-components'; +import { makeStyles } from '@material-ui/core'; +import { useInternalCoderAuth } from '../CoderProvider'; + +type Props = Readonly, 'to'>>; + +const useStyles = makeStyles(() => ({ + root: { + display: 'block', + maxWidth: 'fit-content', + }, +})); + +export function UnlinkAccountButton({ + className, + onClick, + type = 'button', + ...delegatedProps +}: Props) { + const styles = useStyles(); + const { ejectToken } = useInternalCoderAuth(); + + return ( + { + ejectToken(); + onClick?.(event); + }} + {...delegatedProps} + > + Unlink Coder account + + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts new file mode 100644 index 00000000..752873c4 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthForm'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx new file mode 100644 index 00000000..2a0c7cb1 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; +import type { CoderAuthStatus } from '../CoderProvider'; +import { + mockAppConfig, + mockAuthStates, +} from '../../testHelpers/mockBackstageData'; +import { CoderAuthFormCardWrapper } from './CoderAuthFormCardWrapper'; +import { renderInTestApp } from '@backstage/test-utils'; + +type RenderInputs = Readonly<{ + authStatus: CoderAuthStatus; + childButtonText: string; +}>; + +async function renderAuthWrapper({ + authStatus, + childButtonText, +}: RenderInputs) { + return renderInTestApp( + + + + + , + ); +} + +describe(`${CoderAuthFormCardWrapper.name}`, () => { + it('Displays the main children when the user is authenticated', async () => { + const childButtonText = 'I have secret Coder content!'; + const validStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', + ]; + + for (const authStatus of validStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus, + childButtonText, + }); + + const button = await screen.findByRole('button', { + name: childButtonText, + }); + + // This assertion isn't necessary because findByRole will throw an error + // if the button can't be found within the expected period of time. Doing + // this purely to make the Backstage linter happy + expect(button).toBeInTheDocument(); + unmount(); + } + }); + + it('Hides the main children for any invalid/untrustworthy auth status', async () => { + const childButtonText = 'I should never be visible on the screen!'; + const invalidStatuses: readonly CoderAuthStatus[] = [ + 'deploymentUnavailable', + 'distrusted', + 'initializing', + 'invalid', + 'noInternetConnection', + 'tokenMissing', + ]; + + for (const authStatus of invalidStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus, + childButtonText, + }); + + const button = screen.queryByRole('button', { name: childButtonText }); + expect(button).not.toBeInTheDocument(); + unmount(); + } + }); + + it('Will go back to hiding content if auth state becomes invalid after re-renders', async () => { + const buttonText = "Now you see me, now you don't"; + const { rerender } = await renderAuthWrapper({ + authStatus: 'authenticated', + childButtonText: buttonText, + }); + + // Capture button after it first appears on the screen; findBy will throw if + // the button is not actually visible + const button = await screen.findByRole('button', { name: buttonText }); + + rerender( + + + + + , + ); + + // Assert that the button is gone after the re-render flushes + expect(button).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx new file mode 100644 index 00000000..1fa0f9fc --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { A11yInfoCard, A11yInfoCardProps } from '../A11yInfoCard'; +import { useInternalCoderAuth } from '../CoderProvider'; +import { + type CoderAuthFormProps, + CoderAuthForm, +} from '../CoderAuthForm/CoderAuthForm'; +import { makeStyles } from '@material-ui/core'; + +type Props = A11yInfoCardProps & CoderAuthFormProps; + +const useStyles = makeStyles(theme => ({ + root: { + paddingTop: theme.spacing(6), + paddingBottom: theme.spacing(6), + }, +})); + +export function CoderAuthFormCardWrapper({ + children, + headerContent, + descriptionId, + ...delegatedCardProps +}: Props) { + const { isAuthenticated } = useInternalCoderAuth(); + const styles = useStyles(); + + return ( + Authenticate with Coder + } + {...delegatedCardProps} + > + {isAuthenticated ? ( + <>{children} + ) : ( +
    + +
    + )} +
    + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts new file mode 100644 index 00000000..e59d2626 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthFormCardWrapper'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx new file mode 100644 index 00000000..7c39fc95 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -0,0 +1,145 @@ +import React, { type HTMLAttributes, useState } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { makeStyles } from '@material-ui/core'; +import { LinkButton } from '@backstage/core-components'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; +import { CoderAuthForm } from '../CoderAuthForm/CoderAuthForm'; + +const useStyles = makeStyles(theme => ({ + trigger: { + cursor: 'pointer', + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + width: 'fit-content', + border: 'none', + fontWeight: 600, + borderRadius: theme.shape.borderRadius, + transition: '10s color ease-in-out', + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + boxShadow: theme.shadows[10], + + '&:hover': { + backgroundColor: theme.palette.primary.dark, + boxShadow: theme.shadows[15], + }, + }, + + dialogContainer: { + width: '100%', + height: '100%', + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'center', + alignItems: 'center', + }, + + dialogPaper: { + width: '100%', + }, + + dialogTitle: { + fontSize: '24px', + borderBottom: `${theme.palette.divider} 1px solid`, + padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`, + }, + + contentContainer: { + padding: `${theme.spacing(6)}px ${theme.spacing(3)}px 0`, + }, + + actionsRow: { + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'center', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing( + 6, + )}px`, + }, + + closeButton: { + letterSpacing: '0.05em', + padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`, + color: theme.palette.primary.main, + + '&:hover': { + textDecoration: 'none', + }, + }, +})); + +type DialogProps = Readonly< + Omit, 'onClick' | 'className'> & { + open?: boolean; + onOpen?: () => void; + onClose?: () => void; + triggerClassName?: string; + } +>; + +export function CoderAuthFormDialog({ + children, + onOpen, + onClose, + triggerClassName, + open: outerIsOpen, +}: DialogProps) { + const hookId = useId(); + const styles = useStyles(); + const [innerIsOpen, setInnerIsOpen] = useState(false); + + const handleClose = () => { + setInnerIsOpen(false); + onClose?.(); + }; + + const isOpen = outerIsOpen ?? innerIsOpen; + const titleId = `${hookId}-dialog-title`; + const descriptionId = `${hookId}-dialog-description`; + + return ( + <> + + + + + Authenticate with Coder + + + + + + + + + Close + + + + + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts new file mode 100644 index 00000000..3b1069e3 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthFormDialog'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts deleted file mode 100644 index 3d0896b5..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CoderAuthWrapper'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 852abce1..c9b6fbb1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -1,24 +1,34 @@ import React, { type PropsWithChildren, createContext, + useCallback, useContext, useEffect, + useLayoutEffect, + useRef, useState, } from 'react'; - +import { createPortal } from 'react-dom'; import { + type QueryCacheNotifyEvent, type UseQueryResult, useQuery, useQueryClient, } from '@tanstack/react-query'; +import { useApi } from '@backstage/core-plugin-api'; +import { type Theme, makeStyles } from '@material-ui/core'; +import { useId } from '../../hooks/hookPolyfills'; import { BackstageHttpError } from '../../api/errors'; import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; import { coderClientApiRef } from '../../api/CoderClient'; -import { useApi } from '@backstage/core-plugin-api'; +import { CoderLogo } from '../CoderLogo'; +import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; +const BACKSTAGE_APP_ROOT_ID = '#root'; +const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; // Handles auth edge case where a previously-valid token can't be verified. Not @@ -55,52 +65,28 @@ export type CoderAuthStatus = AuthState['status']; export type CoderAuth = Readonly< AuthState & { isAuthenticated: boolean; - tokenLoadedOnMount: boolean; registerNewToken: (newToken: string) => void; ejectToken: () => void; } >; -function isAuthValid(state: AuthState): boolean { - return ( - state.status === 'authenticated' || - state.status === 'distrustedWithGracePeriod' - ); -} - -type ValidCoderAuth = Extract< - CoderAuth, - { status: 'authenticated' | 'distrustedWithGracePeriod' } ->; - -export function assertValidCoderAuth( - auth: CoderAuth, -): asserts auth is ValidCoderAuth { - if (!isAuthValid(auth)) { - throw new Error('Coder auth is not valid'); - } -} - -export const AuthContext = createContext(null); +type TrackComponent = (componentInstanceId: string) => () => void; +export const AuthTrackingContext = createContext(null); +export const AuthStateContext = createContext(null); -export function useCoderAuth(): CoderAuth { - const contextValue = useContext(AuthContext); - if (contextValue === null) { - throw new Error( - `Hook ${useCoderAuth.name} is being called outside of CoderProvider`, - ); - } - - return contextValue; -} +const validAuthStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', +]; -type CoderAuthProviderProps = Readonly>; +function useAuthState(): CoderAuth { + const [authToken, setAuthToken] = useState( + () => window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? '', + ); -export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - // Need to split hairs, because the query object can be disabled. Only want to - // expose the initializing state if the app mounts with a token already in - // localStorage - const [authToken, setAuthToken] = useState(readAuthToken); + // Need to differentiate the current token from the token loaded on mount + // because the query object can be disabled. Only want to expose the + // initializing state if the app mounts with a token already in localStorage const [readonlyInitialAuthToken] = useState(authToken); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); @@ -112,6 +98,8 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, + + // Can't use !query.state.data because we want to refetch on undefined cases refetchOnWindowFocus: query => query.state.data !== false, }); @@ -123,8 +111,8 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { }); // Mid-render state sync to avoid unnecessary re-renders that useEffect would - // introduce, especially since we don't know how costly re-renders could be in - // someone's arbitrarily-large Backstage deployment + // introduce. We don't know how costly re-renders could be in someone's + // arbitrarily-large Backstage deployment, so erring on the side of caution if (!isInsideGracePeriod && authState.status === 'authenticated') { setIsInsideGracePeriod(true); } @@ -152,13 +140,14 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { // outside React because we let the user connect their own queryClient const queryClient = useQueryClient(); useEffect(() => { - let isRefetchingTokenQuery = false; - const queryCache = queryClient.getQueryCache(); + // Pseudo-mutex; makes sure that if we get a bunch of errors, only one + // revalidation will be processed at a time + let isRevalidatingToken = false; - const unsubscribe = queryCache.subscribe(async event => { + const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { const queryError = event.query.state.error; const shouldRevalidate = - !isRefetchingTokenQuery && + !isRevalidatingToken && BackstageHttpError.isInstance(queryError) && queryError.status === 401; @@ -166,36 +155,125 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { return; } - isRefetchingTokenQuery = true; + isRevalidatingToken = true; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); - isRefetchingTokenQuery = false; - }); + isRevalidatingToken = false; + }; + const queryCache = queryClient.getQueryCache(); + const unsubscribe = queryCache.subscribe(revalidateTokenOnError); return unsubscribe; }, [queryClient]); - return ( - { - if (newToken !== '') { - setAuthToken(newToken); - } - }, - ejectToken: () => { - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - setAuthToken(''); - }, - }} - > - {children} - - ); -}; + return { + ...authState, + isAuthenticated: validAuthStatuses.includes(authState.status), + registerNewToken: newToken => { + if (newToken !== '') { + setAuthToken(newToken); + } + }, + ejectToken: () => { + setAuthToken(''); + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); + }, + }; +} + +type AuthFallbackState = Readonly<{ + trackComponent: TrackComponent; + hasNoAuthInputs: boolean; +}>; + +function useAuthFallbackState(): AuthFallbackState { + // Can't do state syncs or anything else that would normally minimize + // re-renders here because we have to wait for the entire application to + // complete its initial render before we can decide if we need a fallback UI + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + + // Not the biggest fan of needing to keep the two pieces of state in sync, but + // setting the render state to a simple boolean rather than the whole Set + // means that we re-render only when we go from 0 trackers to 1+, or from 1+ + // trackers to 0. We don't care about the exact number of components being + // tracked - just whether we have any at all + const [hasTrackers, setHasTrackers] = useState(false); + const trackedComponentsRef = useRef>(null!); + if (trackedComponentsRef.current === null) { + trackedComponentsRef.current = new Set(); + } + + const trackComponent = useCallback((componentId: string) => { + // React will bail out of re-renders if you dispatch the same state value + // that it already has, and that's easier to guarantee since the UI state + // only has a primitive. Calling this function too often should cause no + // problems, and most calls should be a no-op + const syncTrackerToUi = () => { + setHasTrackers(trackedComponentsRef.current.size > 0); + }; + + trackedComponentsRef.current.add(componentId); + syncTrackerToUi(); + + return () => { + trackedComponentsRef.current.delete(componentId); + syncTrackerToUi(); + }; + }, []); + + return { + trackComponent, + hasNoAuthInputs: isMounted && !hasTrackers, + }; +} + +/** + * Exposes auth state for other components, but has additional logic for spying + * on consumers of the hook. + * + * Caveats: + * 1. This hook should *NEVER* be exposed to the end user + * 2. All official Coder plugin components should favor this hook over + * useEndUserCoderAuth when possible + * + * A fallback UI for letting the user input auth information will appear if + * there are no official Coder components that are able to give the user a way + * to do that through normal user flows. + */ +export function useInternalCoderAuth(): CoderAuth { + const trackComponent = useContext(AuthTrackingContext); + if (trackComponent === null) { + throw new Error('Unable to retrieve state for displaying fallback auth UI'); + } + + // Assuming trackComponent is set up properly, the values of it and instanceId + // should both be stable until whatever component is using this hook unmounts. + // Values only added to dependency array to satisfy ESLint + const instanceId = useId(); + useEffect(() => { + const cleanupTracking = trackComponent(instanceId); + return cleanupTracking; + }, [instanceId, trackComponent]); + + return useEndUserCoderAuth(); +} + +/** + * Exposes Coder auth state to the rest of the UI. + */ +// This hook should only be used by end users trying to use the Coder SDK inside +// Backstage. The hook is renamed on final export to avoid confusion +export function useEndUserCoderAuth(): CoderAuth { + const authContextValue = useContext(AuthStateContext); + if (authContextValue === null) { + throw new Error('Cannot retrieve auth information from CoderProvider'); + } + + return authContextValue; +} type GenerateAuthStateInputs = Readonly<{ authToken: string; @@ -331,6 +409,218 @@ function generateAuthState({ }; } -function readAuthToken(): string { - return window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; +// Have to get the root of the React application to adjust its dimensions when +// we display the fallback UI. Sadly, we can't assert that the root is always +// defined from outside a UI component, because throwing any errors here would +// blow up the entire Backstage application, and wreck all the other plugins +const mainAppRoot = document.querySelector(BACKSTAGE_APP_ROOT_ID); + +type StyleKey = 'landmarkWrapper' | 'dialogButton' | 'logo'; +type StyleProps = Readonly<{ isDialogOpen: boolean }>; + +const useFallbackStyles = makeStyles(theme => ({ + landmarkWrapper: ({ isDialogOpen }) => ({ + zIndex: isDialogOpen ? 0 : 9999, + position: 'fixed', + bottom: theme.spacing(2), + width: '100%', + maxWidth: 'fit-content', + left: '50%', + transform: 'translateX(-50%)', + }), + + dialogButton: { + display: 'flex', + flexFlow: 'row nowrap', + columnGap: theme.spacing(1), + alignItems: 'center', + }, + + logo: { + fill: theme.palette.primary.contrastText, + width: theme.spacing(3), + }, +})); + +function FallbackAuthUi() { + /** + * Add additional padding to the bottom of the main app to make sure that even + * with the fallback UI in place, it won't permanently cover up any of the + * other content as long as the user scrolls down far enough. + * + * Involves jumping through a bunch of hoops since we don't have 100% control + * over the Backstage application. Need to minimize risks of breaking existing + * Backstage styling or other plugins + */ + const fallbackRef = useRef(null); + useLayoutEffect(() => { + const fallback = fallbackRef.current; + const mainAppContainer = + mainAppRoot?.querySelector('main') ?? null; + + if (fallback === null || mainAppContainer === null) { + return undefined; + } + + // Adding a new style node lets us override the existing styles via the CSS + // cascade rather than directly modifying them, which minimizes the risks of + // breaking anything. If we were to modify the styles and try resetting them + // with the cleanup function, there's a risk the cleanup function would have + // closure over stale values and try "resetting" things to a value that is + // no longer used + const overrideStyleNode = document.createElement('style'); + overrideStyleNode.type = 'text/css'; + + // Using ComputedStyle objects because they maintain live links to computed + // properties. Plus, since most styling goes through MUI's makeStyles (which + // is based on CSS classes), trying to access properties directly off the + // nodes won't always work + const liveAppStyles = getComputedStyle(mainAppContainer); + const liveFallbackStyles = getComputedStyle(fallback); + + let prevPaddingBottom: string | undefined = undefined; + const updatePaddingForFallbackUi: MutationCallback = () => { + const prevInnerHtml = overrideStyleNode.innerHTML; + overrideStyleNode.innerHTML = ''; + const paddingBottomWithNoOverride = liveAppStyles.paddingBottom || '0px'; + + if (paddingBottomWithNoOverride === prevPaddingBottom) { + overrideStyleNode.innerHTML = prevInnerHtml; + return; + } + + // parseInt will automatically remove units from bottom property + const fallbackBottom = parseInt(liveFallbackStyles.bottom || '0', 10); + const normalized = Number.isNaN(fallbackBottom) ? 0 : fallbackBottom; + const paddingToAdd = fallback.offsetHeight + normalized; + + overrideStyleNode.innerHTML = ` + .${FALLBACK_UI_OVERRIDE_CLASS_NAME} { + padding-bottom: calc(${paddingBottomWithNoOverride} + ${paddingToAdd}px) !important; + } + `; + + // Only update prev padding after state changes have definitely succeeded + prevPaddingBottom = paddingBottomWithNoOverride; + }; + + const observer = new MutationObserver(updatePaddingForFallbackUi); + observer.observe(document.head, { childList: true }); + observer.observe(mainAppContainer, { + childList: false, + subtree: false, + attributes: true, + attributeFilter: ['class', 'style'], + }); + + // Applying mutations after we've started observing will trigger the + // callback, but as long as it's set up properly, the user shouldn't notice. + // Also serves a way to ensure the mutation callback runs at least once + document.head.append(overrideStyleNode); + mainAppContainer.classList.add(FALLBACK_UI_OVERRIDE_CLASS_NAME); + + return () => { + // Be sure to disconnect observer before applying other cleanup mutations + observer.disconnect(); + overrideStyleNode.remove(); + mainAppContainer.classList.remove(FALLBACK_UI_OVERRIDE_CLASS_NAME); + }; + }, []); + + const hookId = useId(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const styles = useFallbackStyles({ isDialogOpen }); + + // Wrapping fallback button in landmark so that screen reader users can jump + // straight to the button from a screen reader directory rotor, and don't have + // to navigate through every single other element first + const landmarkId = `${hookId}-landmark`; + const fallbackUi = ( +
    + + + setIsDialogOpen(true)} + onClose={() => setIsDialogOpen(false)} + triggerClassName={styles.dialogButton} + > + + Authenticate with Coder + +
    + ); + + return createPortal(fallbackUi, document.body); +} + +/** + * Sorry about how wacky this approach is, but this setup should simplify the + * code literally everywhere else in the plugin. + * + * The setup is that we have two versions of the tracking context: one that has + * the live trackComponent function, and one that has the dummy. The main parts + * of the UI get the live version, and the parts of the UI that deal with the + * fallback auth UI get the dummy version. + * + * By having two contexts, we can dynamically expose or hide the tracking + * state for any components that use any version of the Coder auth state. All + * other components can use the same hook without being aware of where they're + * being mounted. That means you can use the exact same components in either + * region without needing to rewrite anything outside this file. + * + * Any other component that uses useInternalCoderAuth will reach up the + * component tree until it can grab *some* kind of tracking function. The hook + * only cares about whether it got a function at all; it doesn't care about what + * it does. The hook will call the function either way, but only the components + * in the "live" region will influence whether the fallback UI should be + * displayed. + * + * Dummy function defined outside the component to prevent risk of needless + * re-renders through Context. + */ + +/** + * A dummy version of the component tracker function. + * + * In production, this is used to define a dummy version of the context + * dependency for the "fallback auth UI" portion of the app. + * + * In testing, this is used for the vast majority of component tests to provide + * the tracker dependency and make sure that the components can properly render + * without having to be wired up to the entire plugin. + */ +export const dummyTrackComponent: TrackComponent = () => { + // Deliberately perform a no-op on initial call + return () => { + // And deliberately perform a no-op on cleanup + }; +}; + +export function CoderAuthProvider({ + children, +}: Readonly>) { + const authState = useAuthState(); + const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); + const needFallbackUi = !authState.isAuthenticated && hasNoAuthInputs; + + return ( + + + {children} + + + {needFallbackUi && ( + + + + )} + + ); } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 955aae28..73acc13c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -12,7 +12,7 @@ import { import { CoderProvider } from './CoderProvider'; import { useCoderAppConfig } from './CoderAppConfigProvider'; -import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; +import { type CoderAuth, useInternalCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, @@ -56,7 +56,7 @@ describe(`${CoderProvider.name}`, () => { describe('Auth', () => { // Can't use the render helpers because they all assume that the auth isn't // core to the functionality. In this case, you do need to bring in the full - // CoderProvider + // CoderProvider to make sure that it's working properly const renderUseCoderAuth = () => { const discoveryApi = getMockDiscoveryApi(); const configApi = getMockConfigApi(); @@ -70,7 +70,7 @@ describe(`${CoderProvider.name}`, () => { apis: { urlSync, identityApi }, }); - return renderHook(useCoderAuth, { + return renderHook(useInternalCoderAuth, { wrapper: ({ children }) => ( { const styles = useStyles(); return ( - - - - - - } - /> - + + + + + } + /> + } + {...props} + >
    diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 57a41922..3d9dbcf6 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -7,7 +7,7 @@ import React, { } from 'react'; import { useId } from '../../hooks/hookPolyfills'; -import { useCoderAuth } from '../CoderProvider'; +import { useInternalCoderAuth } from '../CoderProvider'; import { useWorkspacesCardContext } from './Root'; import { VisuallyHidden } from '../VisuallyHidden'; @@ -102,7 +102,7 @@ export const ExtraActionsButton = ({ const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useInternalCoderAuth(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx index 8c67d5e5..b96f2361 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx @@ -1,50 +1,37 @@ import React, { HTMLAttributes, ReactNode } from 'react'; -import { Theme, makeStyles } from '@material-ui/core'; +import { type Theme, makeStyles } from '@material-ui/core'; import { useWorkspacesCardContext } from './Root'; +import type { HtmlHeader } from '../../typesConstants'; type StyleKey = 'root' | 'header' | 'hgroup' | 'subheader'; - -type MakeStylesInputs = Readonly<{ - fullBleedLayout: boolean; -}>; - -const useStyles = makeStyles(theme => ({ - root: ({ fullBleedLayout }) => ({ +const useStyles = makeStyles(theme => ({ + root: { color: theme.palette.text.primary, display: 'flex', flexFlow: 'row nowrap', alignItems: 'center', gap: theme.spacing(1), - - // Have to jump through some hoops for the border; have to extend out the - // root to make sure that the border stretches all the way across the - // parent, and then add padding back to just the main content - borderBottom: `1px solid ${theme.palette.divider}`, - marginLeft: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, - marginRight: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, - padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px ${theme.spacing( - 2.5, - )}px`, - }), + }, hgroup: { marginRight: 'auto', }, header: { - fontSize: '24px', + fontSize: '1.5rem', lineHeight: 1, margin: 0, }, subheader: { margin: '0', + fontSize: '0.875rem', + fontWeight: 400, color: theme.palette.text.secondary, paddingTop: theme.spacing(0.5), }, })); -type HtmlHeader = `h${1 | 2 | 3 | 4 | 5 | 6}`; type ClassName = `${Exclude}ClassName`; type HeaderProps = Readonly< @@ -67,11 +54,10 @@ export const HeaderRow = ({ subheaderClassName, activeRepoFilteringText, headerText = 'Coder Workspaces', - fullBleedLayout = true, ...delegatedProps }: HeaderProps) => { const { headerId, workspacesConfig } = useWorkspacesCardContext(); - const styles = useStyles({ fullBleedLayout }); + const styles = useStyles(); const HeadingComponent = headerLevel ?? 'h2'; const { repoUrl } = workspacesConfig; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 9a2d118f..0866d95a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -4,6 +4,7 @@ */ import React, { type HTMLAttributes, + type ReactNode, createContext, useContext, useState, @@ -14,11 +15,9 @@ import { useCoderWorkspacesConfig, type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; - import type { Workspace } from '../../typesConstants'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; -import { Card } from '../Card'; -import { CoderAuthWrapper } from '../CoderAuthWrapper'; +import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; export type WorkspacesQuery = UseQueryResult; @@ -40,12 +39,14 @@ export type WorkspacesCardProps = Readonly< defaultQueryFilter?: string; onFilterChange?: (newFilter: string) => void; readEntityData?: boolean; + headerContent?: ReactNode; } >; const InnerRoot = ({ children, className, + headerContent, queryFilter: outerFilter, onFilterChange: onOuterFilterChange, defaultQueryFilter = 'owner:me', @@ -65,44 +66,49 @@ const InnerRoot = ({ const headerId = `${hookId}-header`; return ( - - { - setInnerFilter(newFilter); - onOuterFilterChange?.(newFilter); - }, - }} + { + setInnerFilter(newFilter); + onOuterFilterChange?.(newFilter); + }, + }} + > + - {/* - * 2024-01-31: This output is a
    , but that should be changed to a - * once that element is supported by more browsers. Setting up - * accessibility markup and landmark behavior manually in the meantime - */} - - {/* Want to expose the overall container as a form for good - semantics and screen reader support, but since there isn't an - explicit submission process (queries happen automatically), it - felt better to use a
    with a role override to side-step edge - cases around keyboard input and button children that native - elements automatically introduce */} -
    {children}
    - - - + {/* Want to expose the overall container as a form for good + semantics and screen reader support, but since there isn't an + explicit submission process (queries happen automatically), it + felt better to use a
    with a role override to side-step edge + cases around keyboard input and button children that native + elements automatically introduce */} +
    {children}
    + + ); }; export function Root(props: WorkspacesCardProps) { - // Doing this to insulate the user from needing to worry about accidentally - // flipping the value of readEntityData between renders. If this value - // changes, it will cause the component to unmount and remount, but that - // should be painless/maybe invisible compared to having the component throw - // a full error and triggering an error boundary + /** + * Binding the value of readEntityData as a render key to make using the + * component less painful to use overall for end users. + * + * Without this, the component will throw an error anytime the user flips the + * value of readEntityData from false to true, or vice-versa. + * + * With a render key, whenever the key changes, the whole component will + * unmount and then remount. This isn't a problem because all its important + * state is stored outside React via React Query, so on the remount, it can + * reuse the existing state and just has rebuild itself via the new props. + */ const renderKey = String(props.readEntityData ?? false); return ; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index a3b22d3d..4e41ef86 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../api/queryOptions'; import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; import { useCoderSdk } from './useCoderSdk'; -import { useCoderAuth } from '../components/CoderProvider'; +import { useInternalCoderAuth } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -13,7 +13,7 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const auth = useCoderAuth(); + const auth = useInternalCoderAuth(); const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 5dad65dc..2aaaab89 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -58,16 +58,6 @@ export const CoderProvider = coderPlugin.provide( }), ); -export const CoderAuthWrapper = coderPlugin.provide( - createComponentExtension({ - name: 'CoderAuthWrapper', - component: { - lazy: () => - import('./components/CoderAuthWrapper').then(m => m.CoderAuthWrapper), - }, - }), -); - export const CoderErrorBoundary = coderPlugin.provide( createComponentExtension({ name: 'CoderErrorBoundary', @@ -192,12 +182,17 @@ export const CoderWorkspacesReminderAccordion = coderPlugin.provide( ); /** - * All custom hooks exposed by the plugin. + * Custom hooks needed for some of the custom Coder components */ -export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; -export { useCoderWorkspacesQuery } from './hooks/useCoderWorkspacesQuery'; export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root'; +/** + * General custom hooks that can be used in various places. + */ +export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +export { useCoderSdk } from './hooks/useCoderSdk'; +export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; + /** * All custom types */ diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 28e258f5..34f11218 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -159,7 +159,6 @@ export const mockCoderWorkspacesConfig = (() => { const authedState = { token: mockCoderAuthToken, error: undefined, - tokenLoadedOnMount: true, isAuthenticated: true, registerNewToken: jest.fn(), ejectToken: jest.fn(), @@ -168,7 +167,6 @@ const authedState = { const notAuthedState = { token: undefined, error: undefined, - tokenLoadedOnMount: false, isAuthenticated: false, registerNewToken: jest.fn(), ejectToken: jest.fn(), diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 0cef032f..86ceedcb 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -17,8 +17,10 @@ import { type CoderAuthStatus, type CoderAppConfig, type CoderProviderProps, - AuthContext, + AuthStateContext, + AuthTrackingContext, CoderAppConfigProvider, + dummyTrackComponent, } from '../components/CoderProvider'; import { mockAppConfig, @@ -128,9 +130,11 @@ export const CoderProviderWithMockAuth = ({ - - {children} - + + + {children} + + @@ -164,7 +168,9 @@ export const renderHookAsCoderEntity = async < queryClient={mockQueryClient} authStatus={authStatus} > - {children} + + <>{children} + ); diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index d9922920..76551f89 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -103,3 +103,11 @@ export type User = Readonly<{ username: string; avatar_url: string; }>; + +/** + * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to + * retrying a failed API request 3 times before exposing an error to the UI + */ +export const DEFAULT_TANSTACK_QUERY_RETRY_COUNT = 3; + +export type HtmlHeader = `h${1 | 2 | 3 | 4 | 5 | 6}`; diff --git a/yarn.lock b/yarn.lock index b13b38c9..e7553d7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8719,9 +8719,9 @@ integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== "@types/react-dom@*", "@types/react-dom@^18.0.0": - version "18.2.21" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" - integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== dependencies: "@types/react" "*" @@ -8757,12 +8757,11 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": - version "18.2.64" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" - integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== + version "18.3.2" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd" + integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w== dependencies: "@types/prop-types" "*" - "@types/scheduler" "*" csstype "^3.0.2" "@types/react@^16.13.1 || ^17.0.0": @@ -8801,7 +8800,7 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*", "@types/scheduler@^0.16": +"@types/scheduler@^0.16": version "0.16.8" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== @@ -20249,6 +20248,14 @@ react-dom@^18.0.2: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + react-double-scrollbar@0.0.15: version "0.0.15" resolved "https://registry.yarnpkg.com/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz#e915ab8cb3b959877075f49436debfdb04288fe4" @@ -21181,6 +21188,13 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + schema-utils@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" @@ -21918,16 +21932,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22001,7 +22006,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22015,13 +22020,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23830,7 +23828,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23848,15 +23846,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 06d24da68166bec88962153ad2256798ba6c2a67 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 31 May 2024 11:36:29 -0400 Subject: [PATCH 26/33] chore(Coder plugin): import preview version of Coder SDK into plugin (#130) * chore: add vendored version of experimental Coder SDK * fix: improve data hiding for CoderSdk * docs: update typo * fix: add additional properties to hide from SDK --- plugins/backstage-plugin-coder/package.json | 3 + .../src/api/vendoredSdk/README.md | 20 + .../src/api/vendoredSdk/api/api.ts | 1940 ++++++++++++ .../src/api/vendoredSdk/api/errors.ts | 124 + .../src/api/vendoredSdk/api/typesGenerated.ts | 2599 +++++++++++++++++ .../src/api/vendoredSdk/index.ts | 36 + .../src/api/vendoredSdk/utils/delay.ts | 4 + yarn.lock | 30 +- 8 files changed, 4741 insertions(+), 15 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index e21caf74..1d21b960 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -42,6 +42,8 @@ "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", "axios": "^1.6.8", + "dayjs": "^1.11.11", + "ua-parser-js": "^1.0.37", "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, @@ -57,6 +59,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.0.0", + "@types/ua-parser-js": "^0.7.39", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md b/plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md new file mode 100644 index 00000000..354acb1c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md @@ -0,0 +1,20 @@ +# Coder SDK - Experimental Vendored Version + +This is a vendored version of the main API files from the +[core Coder OSS repo](https://github.com/coder/coder/tree/main/site/src/api). All files (aside from test files) have been copied over directly, with only a +few changes made to satisfy default Backstage ESLint rules. + +While there is a risk of this getting out of sync with the versions of the +files in Coder OSS, the Coder API itself should be treated as stable. Breaking +changes are only made when absolutely necessary. + +## General approach + +- Copy over relevant files from Coder OSS and place them in relevant folders + - As much as possible, the file structure of the vendored files should match the file structure of Coder OSS to make it easier to copy updated files over. +- Have a single file at the top level of this directory that exports out the files for consumption elsewhere in the plugin. No plugin code should interact with the vendored files directly. + +## Eventual plans + +Coder has eventual plans to create a true SDK published through NPM. Once +that is published, all of this vendored code should be removed in favor of it. diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts new file mode 100644 index 00000000..e0eafd1d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts @@ -0,0 +1,1940 @@ +/** + * @file Coder is starting to import the Coder API file into more and more + * external projects, as a "pseudo-SDK". We are not at a stage where we are + * ready to commit to maintaining a public SDK, but we need equivalent + * functionality in other places. + * + * Message somebody from Team Blueberry if you need more context, but so far, + * these projects are importing the file: + * + * - The Coder VS Code extension + * @see {@link https://github.com/coder/vscode-coder} + * - The Coder Backstage plugin + * @see {@link https://github.com/coder/backstage-plugins} + * + * It is important that this file not do any aliased imports, or else the other + * consumers could break (particularly for platforms that limit how much you can + * touch their configuration files, like Backstage). Relative imports are still + * safe, though. + * + * For example, `utils/delay` must be imported using `../utils/delay` instead. + */ +import globalAxios, { type AxiosInstance, isAxiosError } from 'axios'; +import type dayjs from 'dayjs'; +import userAgentParser from 'ua-parser-js'; +import { delay } from '../utils/delay'; +import * as TypesGen from './typesGenerated'; + +const getMissingParameters = ( + oldBuildParameters: TypesGen.WorkspaceBuildParameter[], + newBuildParameters: TypesGen.WorkspaceBuildParameter[], + templateParameters: TypesGen.TemplateVersionParameter[], +) => { + const missingParameters: TypesGen.TemplateVersionParameter[] = []; + const requiredParameters: TypesGen.TemplateVersionParameter[] = []; + + templateParameters.forEach(p => { + // It is mutable and required. Mutable values can be changed after so we + // don't need to ask them if they are not required. + const isMutableAndRequired = p.mutable && p.required; + // Is immutable, so we can check if it is its first time on the build + const isImmutable = !p.mutable; + + if (isMutableAndRequired || isImmutable) { + requiredParameters.push(p); + } + }); + + for (const parameter of requiredParameters) { + // Check if there is a new value + let buildParameter = newBuildParameters.find( + p => p.name === parameter.name, + ); + + // If not, get the old one + if (!buildParameter) { + buildParameter = oldBuildParameters.find(p => p.name === parameter.name); + } + + // If there is a value from the new or old one, it is not missed + if (buildParameter) { + continue; + } + + missingParameters.push(parameter); + } + + // Check if parameter "options" changed and we can't use old build parameters. + templateParameters.forEach(templateParameter => { + if (templateParameter.options.length === 0) { + return; + } + + // Check if there is a new value + let buildParameter = newBuildParameters.find( + p => p.name === templateParameter.name, + ); + + // If not, get the old one + if (!buildParameter) { + buildParameter = oldBuildParameters.find( + p => p.name === templateParameter.name, + ); + } + + if (!buildParameter) { + return; + } + + const matchingOption = templateParameter.options.find( + option => option.value === buildParameter?.value, + ); + if (!matchingOption) { + missingParameters.push(templateParameter); + } + }); + return missingParameters; +}; + +/** + * + * @param agentId + * @returns An EventSource that emits agent metadata event objects + * (ServerSentEvent) + */ +export const watchAgentMetadata = (agentId: string): EventSource => { + return new EventSource( + `${location.protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata`, + { withCredentials: true }, + ); +}; + +/** + * @returns {EventSource} An EventSource that emits workspace event objects + * (ServerSentEvent) + */ +export const watchWorkspace = (workspaceId: string): EventSource => { + return new EventSource( + `${location.protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch`, + { withCredentials: true }, + ); +}; + +export const getURLWithSearchParams = ( + basePath: string, + options?: SearchParamOptions, +): string => { + if (!options) { + return basePath; + } + + const searchParams = new URLSearchParams(); + const keys = Object.keys(options) as (keyof SearchParamOptions)[]; + keys.forEach(key => { + const value = options[key]; + if (value !== undefined && value !== '') { + searchParams.append(key, value.toString()); + } + }); + + const searchString = searchParams.toString(); + return searchString ? `${basePath}?${searchString}` : basePath; +}; + +// withDefaultFeatures sets all unspecified features to not_entitled and +// disabled. +export const withDefaultFeatures = ( + fs: Partial, +): TypesGen.Entitlements['features'] => { + for (const feature of TypesGen.FeatureNames) { + // Skip fields that are already filled. + if (fs[feature] !== undefined) { + continue; + } + + fs[feature] = { + enabled: false, + entitlement: 'not_entitled', + }; + } + + return fs as TypesGen.Entitlements['features']; +}; + +type WatchBuildLogsByTemplateVersionIdOptions = { + after?: number; + onMessage: (log: TypesGen.ProvisionerJobLog) => void; + onDone?: () => void; + onError: (error: Error) => void; +}; + +export const watchBuildLogsByTemplateVersionId = ( + versionId: string, + { + onMessage, + onDone, + onError, + after, + }: WatchBuildLogsByTemplateVersionIdOptions, +) => { + const searchParams = new URLSearchParams({ follow: 'true' }); + if (after !== undefined) { + searchParams.append('after', after.toString()); + } + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket( + `${proto}//${ + location.host + }/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`, + ); + + socket.binaryType = 'blob'; + + socket.addEventListener('message', event => + onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog), + ); + + socket.addEventListener('error', () => { + onError(new Error('Connection for logs failed.')); + socket.close(); + }); + + socket.addEventListener('close', () => { + // When the socket closes, logs have finished streaming! + onDone?.(); + }); + + return socket; +}; + +export const watchWorkspaceAgentLogs = ( + agentId: string, + { after, onMessage, onDone, onError }: WatchWorkspaceAgentLogsOptions, +) => { + // WebSocket compression in Safari (confirmed in 16.5) is broken when + // the server sends large messages. The following error is seen: + // + // WebSocket connection to 'wss://.../logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error + // + const noCompression = + userAgentParser(navigator.userAgent).browser.name === 'Safari' + ? '&no_compression' + : ''; + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/workspaceagents/${agentId}/logs?follow&after=${after}${noCompression}`, + ); + socket.binaryType = 'blob'; + + socket.addEventListener('message', event => { + const logs = JSON.parse(event.data) as TypesGen.WorkspaceAgentLog[]; + onMessage(logs); + }); + + socket.addEventListener('error', () => { + onError(new Error('socket errored')); + }); + + socket.addEventListener('close', () => { + onDone?.(); + }); + + return socket; +}; + +type WatchWorkspaceAgentLogsOptions = { + after: number; + onMessage: (logs: TypesGen.WorkspaceAgentLog[]) => void; + onDone?: () => void; + onError: (error: Error) => void; +}; + +type WatchBuildLogsByBuildIdOptions = { + after?: number; + onMessage: (log: TypesGen.ProvisionerJobLog) => void; + onDone?: () => void; + onError?: (error: Error) => void; +}; +export const watchBuildLogsByBuildId = ( + buildId: string, + { onMessage, onDone, onError, after }: WatchBuildLogsByBuildIdOptions, +) => { + const searchParams = new URLSearchParams({ follow: 'true' }); + if (after !== undefined) { + searchParams.append('after', after.toString()); + } + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket( + `${proto}//${ + location.host + }/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`, + ); + socket.binaryType = 'blob'; + + socket.addEventListener('message', event => + onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog), + ); + + socket.addEventListener('error', () => { + onError?.(new Error('Connection for logs failed.')); + socket.close(); + }); + + socket.addEventListener('close', () => { + // When the socket closes, logs have finished streaming! + onDone?.(); + }); + + return socket; +}; + +// This is the base header that is used for several requests. This is defined as +// a readonly value, but only copies of it should be passed into the API calls, +// because Axios is able to mutate the headers +const BASE_CONTENT_TYPE_JSON = { + 'Content-Type': 'application/json', +} as const satisfies HeadersInit; + +type TemplateOptions = Readonly<{ + readonly deprecated?: boolean; +}>; + +type SearchParamOptions = TypesGen.Pagination & { + q?: string; +}; + +type RestartWorkspaceParameters = Readonly<{ + workspace: TypesGen.Workspace; + buildParameters?: TypesGen.WorkspaceBuildParameter[]; +}>; + +export type DeleteWorkspaceOptions = Pick< + TypesGen.CreateWorkspaceBuildRequest, + 'log_level' & 'orphan' +>; + +type Claims = { + license_expires: number; + account_type?: string; + account_id?: string; + trial: boolean; + all_features: boolean; + version: number; + features: Record; + require_telemetry?: boolean; +}; + +export type GetLicensesResponse = Omit & { + claims: Claims; + expires_at: string; +}; + +export type InsightsParams = { + start_time: string; + end_time: string; + template_ids: string; +}; + +export type InsightsTemplateParams = InsightsParams & { + interval: 'day' | 'week'; +}; + +export type GetJFrogXRayScanParams = { + workspaceId: string; + agentId: string; +}; + +export class MissingBuildParameters extends Error { + parameters: TypesGen.TemplateVersionParameter[] = []; + versionId: string; + + constructor( + parameters: TypesGen.TemplateVersionParameter[], + versionId: string, + ) { + super('Missing build parameters.'); + this.parameters = parameters; + this.versionId = versionId; + } +} + +/** + * This is the container for all API methods. It's split off to make it more + * clear where API methods should go, but it is eventually merged into the Api + * class with a more flat hierarchy + * + * All public methods should be defined as arrow functions to ensure that they + * can be passed around the React UI without losing their `this` context. + * + * This is one of the few cases where you have to worry about the difference + * between traditional methods and arrow function properties. Arrow functions + * disable JS's dynamic scope, and force all `this` references to resolve via + * lexical scope. + */ +class ApiMethods { + constructor(protected readonly axios: AxiosInstance) {} + + login = async ( + email: string, + password: string, + ): Promise => { + const payload = JSON.stringify({ email, password }); + const response = await this.axios.post( + '/api/v2/users/login', + payload, + { headers: { ...BASE_CONTENT_TYPE_JSON } }, + ); + + return response.data; + }; + + convertToOAUTH = async (request: TypesGen.ConvertLoginRequest) => { + const response = await this.axios.post( + '/api/v2/users/me/convert-login', + request, + ); + + return response.data; + }; + + logout = async (): Promise => { + return this.axios.post('/api/v2/users/logout'); + }; + + getAuthenticatedUser = async () => { + const response = await this.axios.get('/api/v2/users/me'); + return response.data; + }; + + getUserParameters = async (templateID: string) => { + const response = await this.axios.get( + `/api/v2/users/me/autofill-parameters?template_id=${templateID}`, + ); + + return response.data; + }; + + getAuthMethods = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/authmethods', + ); + + return response.data; + }; + + getUserLoginType = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/me/login-type', + ); + + return response.data; + }; + + checkAuthorization = async ( + params: TypesGen.AuthorizationRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/authcheck`, + params, + ); + + return response.data; + }; + + getApiKey = async (): Promise => { + const response = await this.axios.post( + '/api/v2/users/me/keys', + ); + + return response.data; + }; + + getTokens = async ( + params: TypesGen.TokensFilter, + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/me/keys/tokens`, + { params }, + ); + + return response.data; + }; + + deleteToken = async (keyId: string): Promise => { + await this.axios.delete(`/api/v2/users/me/keys/${keyId}`); + }; + + createToken = async ( + params: TypesGen.CreateTokenRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/users/me/keys/tokens`, + params, + ); + + return response.data; + }; + + getTokenConfig = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/me/keys/tokens/tokenconfig', + ); + + return response.data; + }; + + getUsers = async ( + options: TypesGen.UsersRequest, + signal?: AbortSignal, + ): Promise => { + const url = getURLWithSearchParams('/api/v2/users', options); + const response = await this.axios.get( + url.toString(), + { signal }, + ); + + return response.data; + }; + + getOrganization = async ( + organizationId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}`, + ); + + return response.data; + }; + + getOrganizations = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/me/organizations', + ); + return response.data; + }; + + getTemplate = async (templateId: string): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}`, + ); + + return response.data; + }; + + getTemplates = async ( + organizationId: string, + options?: TemplateOptions, + ): Promise => { + const params: Record = {}; + if (options?.deprecated !== undefined) { + // Just want to check if it isn't undefined. If it has + // a boolean value, convert it to a string and include + // it as a param. + params.deprecated = String(options.deprecated); + } + + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates`, + { params }, + ); + + return response.data; + }; + + getTemplateByName = async ( + organizationId: string, + name: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/${name}`, + ); + + return response.data; + }; + + getTemplateVersion = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}`, + ); + + return response.data; + }; + + getTemplateVersionResources = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/resources`, + ); + + return response.data; + }; + + getTemplateVersionVariables = async ( + versionId: string, + ): Promise => { + // Defined as separate variable to avoid wonky Prettier formatting because + // the type definition is so long + type VerArray = TypesGen.TemplateVersionVariable[]; + + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/variables`, + ); + + return response.data; + }; + + getTemplateVersions = async ( + templateId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}/versions`, + ); + return response.data; + }; + + getTemplateVersionByName = async ( + organizationId: string, + templateName: string, + versionName: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/${templateName}/versions/${versionName}`, + ); + + return response.data; + }; + + getPreviousTemplateVersionByName = async ( + organizationId: string, + templateName: string, + versionName: string, + ) => { + try { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/${templateName}/versions/${versionName}/previous`, + ); + + return response.data; + } catch (error) { + // When there is no previous version, like the first version of a + // template, the API returns 404 so in this case we can safely return + // undefined + const is404 = + isAxiosError(error) && error.response && error.response.status === 404; + + if (is404) { + return undefined; + } + + throw error; + } + }; + + createTemplateVersion = async ( + organizationId: string, + data: TypesGen.CreateTemplateVersionRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/templateversions`, + data, + ); + + return response.data; + }; + + getTemplateVersionExternalAuth = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/external-auth`, + ); + + return response.data; + }; + + getTemplateVersionRichParameters = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/rich-parameters`, + ); + return response.data; + }; + + createTemplate = async ( + organizationId: string, + data: TypesGen.CreateTemplateRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/templates`, + data, + ); + + return response.data; + }; + + updateActiveTemplateVersion = async ( + templateId: string, + data: TypesGen.UpdateActiveTemplateVersion, + ) => { + const response = await this.axios.patch( + `/api/v2/templates/${templateId}/versions`, + data, + ); + return response.data; + }; + + patchTemplateVersion = async ( + templateVersionId: string, + data: TypesGen.PatchTemplateVersionRequest, + ) => { + const response = await this.axios.patch( + `/api/v2/templateversions/${templateVersionId}`, + data, + ); + + return response.data; + }; + + archiveTemplateVersion = async (templateVersionId: string) => { + const response = await this.axios.post( + `/api/v2/templateversions/${templateVersionId}/archive`, + ); + + return response.data; + }; + + unarchiveTemplateVersion = async (templateVersionId: string) => { + const response = await this.axios.post( + `/api/v2/templateversions/${templateVersionId}/unarchive`, + ); + return response.data; + }; + + updateTemplateMeta = async ( + templateId: string, + data: TypesGen.UpdateTemplateMeta, + ): Promise => { + const response = await this.axios.patch( + `/api/v2/templates/${templateId}`, + data, + ); + + // On 304 response there is no data payload. + if (response.status === 304) { + return null; + } + + return response.data; + }; + + deleteTemplate = async (templateId: string): Promise => { + const response = await this.axios.delete( + `/api/v2/templates/${templateId}`, + ); + + return response.data; + }; + + getWorkspace = async ( + workspaceId: string, + params?: TypesGen.WorkspaceOptions, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceId}`, + { params }, + ); + + return response.data; + }; + + getWorkspaces = async ( + options: TypesGen.WorkspacesRequest, + ): Promise => { + const url = getURLWithSearchParams('/api/v2/workspaces', options); + const response = await this.axios.get(url); + return response.data; + }; + + getWorkspaceByOwnerAndName = async ( + username = 'me', + workspaceName: string, + params?: TypesGen.WorkspaceOptions, + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${username}/workspace/${workspaceName}`, + { params }, + ); + + return response.data; + }; + + getWorkspaceBuildByNumber = async ( + username = 'me', + workspaceName: string, + buildNumber: number, + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`, + ); + + return response.data; + }; + + waitForBuild = (build: TypesGen.WorkspaceBuild) => { + return new Promise((res, reject) => { + void (async () => { + let latestJobInfo: TypesGen.ProvisionerJob | undefined = undefined; + + while ( + // eslint-disable-next-line no-loop-func -- Not great, but should be harmless + !['succeeded', 'canceled'].some(status => + latestJobInfo?.status.includes(status), + ) + ) { + const { job } = await this.getWorkspaceBuildByNumber( + build.workspace_owner_name, + build.workspace_name, + build.build_number, + ); + + latestJobInfo = job; + if (latestJobInfo.status === 'failed') { + return reject(latestJobInfo); + } + + await delay(1000); + } + + return res(latestJobInfo); + })(); + }); + }; + + postWorkspaceBuild = async ( + workspaceId: string, + data: TypesGen.CreateWorkspaceBuildRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/workspaces/${workspaceId}/builds`, + data, + ); + + return response.data; + }; + + startWorkspace = ( + workspaceId: string, + templateVersionId: string, + logLevel?: TypesGen.ProvisionerLogLevel, + buildParameters?: TypesGen.WorkspaceBuildParameter[], + ) => { + return this.postWorkspaceBuild(workspaceId, { + transition: 'start', + template_version_id: templateVersionId, + log_level: logLevel, + rich_parameter_values: buildParameters, + }); + }; + + stopWorkspace = ( + workspaceId: string, + logLevel?: TypesGen.ProvisionerLogLevel, + ) => { + return this.postWorkspaceBuild(workspaceId, { + transition: 'stop', + log_level: logLevel, + }); + }; + + deleteWorkspace = (workspaceId: string, options?: DeleteWorkspaceOptions) => { + return this.postWorkspaceBuild(workspaceId, { + transition: 'delete', + ...options, + }); + }; + + cancelWorkspaceBuild = async ( + workspaceBuildId: TypesGen.WorkspaceBuild['id'], + ): Promise => { + const response = await this.axios.patch( + `/api/v2/workspacebuilds/${workspaceBuildId}/cancel`, + ); + + return response.data; + }; + + updateWorkspaceDormancy = async ( + workspaceId: string, + dormant: boolean, + ): Promise => { + const data: TypesGen.UpdateWorkspaceDormancy = { dormant }; + const response = await this.axios.put( + `/api/v2/workspaces/${workspaceId}/dormant`, + data, + ); + + return response.data; + }; + + updateWorkspaceAutomaticUpdates = async ( + workspaceId: string, + automaticUpdates: TypesGen.AutomaticUpdates, + ): Promise => { + const req: TypesGen.UpdateWorkspaceAutomaticUpdatesRequest = { + automatic_updates: automaticUpdates, + }; + + const response = await this.axios.put( + `/api/v2/workspaces/${workspaceId}/autoupdates`, + req, + ); + + return response.data; + }; + + restartWorkspace = async ({ + workspace, + buildParameters, + }: RestartWorkspaceParameters): Promise => { + const stopBuild = await this.stopWorkspace(workspace.id); + const awaitedStopBuild = await this.waitForBuild(stopBuild); + + // If the restart is canceled halfway through, make sure we bail + if (awaitedStopBuild?.status === 'canceled') { + return; + } + + const startBuild = await this.startWorkspace( + workspace.id, + workspace.latest_build.template_version_id, + undefined, + buildParameters, + ); + + await this.waitForBuild(startBuild); + }; + + cancelTemplateVersionBuild = async ( + templateVersionId: TypesGen.TemplateVersion['id'], + ): Promise => { + const response = await this.axios.patch( + `/api/v2/templateversions/${templateVersionId}/cancel`, + ); + + return response.data; + }; + + createUser = async ( + user: TypesGen.CreateUserRequest, + ): Promise => { + const response = await this.axios.post( + '/api/v2/users', + user, + ); + + return response.data; + }; + + createWorkspace = async ( + organizationId: string, + userId = 'me', + workspace: TypesGen.CreateWorkspaceRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/members/${userId}/workspaces`, + workspace, + ); + + return response.data; + }; + + patchWorkspace = async ( + workspaceId: string, + data: TypesGen.UpdateWorkspaceRequest, + ): Promise => { + await this.axios.patch(`/api/v2/workspaces/${workspaceId}`, data); + }; + + getBuildInfo = async (): Promise => { + const response = await this.axios.get('/api/v2/buildinfo'); + return response.data; + }; + + getUpdateCheck = async (): Promise => { + const response = await this.axios.get('/api/v2/updatecheck'); + return response.data; + }; + + putWorkspaceAutostart = async ( + workspaceID: string, + autostart: TypesGen.UpdateWorkspaceAutostartRequest, + ): Promise => { + const payload = JSON.stringify(autostart); + await this.axios.put( + `/api/v2/workspaces/${workspaceID}/autostart`, + payload, + { headers: { ...BASE_CONTENT_TYPE_JSON } }, + ); + }; + + putWorkspaceAutostop = async ( + workspaceID: string, + ttl: TypesGen.UpdateWorkspaceTTLRequest, + ): Promise => { + const payload = JSON.stringify(ttl); + await this.axios.put(`/api/v2/workspaces/${workspaceID}/ttl`, payload, { + headers: { ...BASE_CONTENT_TYPE_JSON }, + }); + }; + + updateProfile = async ( + userId: string, + data: TypesGen.UpdateUserProfileRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/profile`, + data, + ); + return response.data; + }; + + updateAppearanceSettings = async ( + userId: string, + data: TypesGen.UpdateUserAppearanceSettingsRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/appearance`, + data, + ); + return response.data; + }; + + getUserQuietHoursSchedule = async ( + userId: TypesGen.User['id'], + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${userId}/quiet-hours`, + ); + return response.data; + }; + + updateUserQuietHoursSchedule = async ( + userId: TypesGen.User['id'], + data: TypesGen.UpdateUserQuietHoursScheduleRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/quiet-hours`, + data, + ); + + return response.data; + }; + + activateUser = async ( + userId: TypesGen.User['id'], + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/status/activate`, + ); + return response.data; + }; + + suspendUser = async (userId: TypesGen.User['id']): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/status/suspend`, + ); + + return response.data; + }; + + deleteUser = async (userId: TypesGen.User['id']): Promise => { + await this.axios.delete(`/api/v2/users/${userId}`); + }; + + // API definition: + // https://github.com/coder/coder/blob/db665e7261f3c24a272ccec48233a3e276878239/coderd/users.go#L33-L53 + hasFirstUser = async (): Promise => { + try { + // If it is success, it is true + await this.axios.get('/api/v2/users/first'); + return true; + } catch (error) { + // If it returns a 404, it is false + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + + throw error; + } + }; + + createFirstUser = async ( + req: TypesGen.CreateFirstUserRequest, + ): Promise => { + const response = await this.axios.post(`/api/v2/users/first`, req); + return response.data; + }; + + updateUserPassword = async ( + userId: TypesGen.User['id'], + updatePassword: TypesGen.UpdateUserPasswordRequest, + ): Promise => { + await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword); + }; + + getRoles = async (): Promise> => { + const response = await this.axios.get( + `/api/v2/users/roles`, + ); + + return response.data; + }; + + updateUserRoles = async ( + roles: TypesGen.SlimRole['name'][], + userId: TypesGen.User['id'], + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/roles`, + { roles }, + ); + + return response.data; + }; + + getUserSSHKey = async (userId = 'me'): Promise => { + const response = await this.axios.get( + `/api/v2/users/${userId}/gitsshkey`, + ); + + return response.data; + }; + + regenerateUserSSHKey = async (userId = 'me'): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/gitsshkey`, + ); + + return response.data; + }; + + getWorkspaceBuilds = async ( + workspaceId: string, + req?: TypesGen.WorkspaceBuildsRequest, + ) => { + const response = await this.axios.get( + getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req), + ); + + return response.data; + }; + + getWorkspaceBuildLogs = async ( + buildId: string, + before: Date, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspacebuilds/${buildId}/logs?before=${before.getTime()}`, + ); + + return response.data; + }; + + getWorkspaceAgentLogs = async ( + agentID: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaceagents/${agentID}/logs`, + ); + + return response.data; + }; + + putWorkspaceExtension = async ( + workspaceId: string, + newDeadline: dayjs.Dayjs, + ): Promise => { + await this.axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { + deadline: newDeadline, + }); + }; + + refreshEntitlements = async (): Promise => { + await this.axios.post('/api/v2/licenses/refresh-entitlements'); + }; + + getEntitlements = async (): Promise => { + try { + const response = await this.axios.get( + '/api/v2/entitlements', + ); + + return response.data; + } catch (ex) { + if (isAxiosError(ex) && ex.response?.status === 404) { + return { + errors: [], + features: withDefaultFeatures({}), + has_license: false, + require_telemetry: false, + trial: false, + warnings: [], + refreshed_at: '', + }; + } + throw ex; + } + }; + + getExperiments = async (): Promise => { + try { + const response = await this.axios.get( + '/api/v2/experiments', + ); + + return response.data; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return []; + } + + throw error; + } + }; + + getAvailableExperiments = + async (): Promise => { + try { + const response = await this.axios.get('/api/v2/experiments/available'); + + return response.data; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return { safe: [] }; + } + throw error; + } + }; + + getExternalAuthProvider = async ( + provider: string, + ): Promise => { + const res = await this.axios.get(`/api/v2/external-auth/${provider}`); + return res.data; + }; + + getExternalAuthDevice = async ( + provider: string, + ): Promise => { + const resp = await this.axios.get( + `/api/v2/external-auth/${provider}/device`, + ); + return resp.data; + }; + + exchangeExternalAuthDevice = async ( + provider: string, + req: TypesGen.ExternalAuthDeviceExchange, + ): Promise => { + const resp = await this.axios.post( + `/api/v2/external-auth/${provider}/device`, + req, + ); + + return resp.data; + }; + + getUserExternalAuthProviders = + async (): Promise => { + const resp = await this.axios.get(`/api/v2/external-auth`); + return resp.data; + }; + + unlinkExternalAuthProvider = async (provider: string): Promise => { + const resp = await this.axios.delete(`/api/v2/external-auth/${provider}`); + return resp.data; + }; + + getOAuth2ProviderApps = async ( + filter?: TypesGen.OAuth2ProviderAppFilter, + ): Promise => { + const params = filter?.user_id + ? new URLSearchParams({ user_id: filter.user_id }).toString() + : ''; + + const resp = await this.axios.get(`/api/v2/oauth2-provider/apps?${params}`); + return resp.data; + }; + + getOAuth2ProviderApp = async ( + id: string, + ): Promise => { + const resp = await this.axios.get(`/api/v2/oauth2-provider/apps/${id}`); + return resp.data; + }; + + postOAuth2ProviderApp = async ( + data: TypesGen.PostOAuth2ProviderAppRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/oauth2-provider/apps`, + data, + ); + return response.data; + }; + + putOAuth2ProviderApp = async ( + id: string, + data: TypesGen.PutOAuth2ProviderAppRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/oauth2-provider/apps/${id}`, + data, + ); + return response.data; + }; + + deleteOAuth2ProviderApp = async (id: string): Promise => { + await this.axios.delete(`/api/v2/oauth2-provider/apps/${id}`); + }; + + getOAuth2ProviderAppSecrets = async ( + id: string, + ): Promise => { + const resp = await this.axios.get( + `/api/v2/oauth2-provider/apps/${id}/secrets`, + ); + return resp.data; + }; + + postOAuth2ProviderAppSecret = async ( + id: string, + ): Promise => { + const resp = await this.axios.post( + `/api/v2/oauth2-provider/apps/${id}/secrets`, + ); + return resp.data; + }; + + deleteOAuth2ProviderAppSecret = async ( + appId: string, + secretId: string, + ): Promise => { + await this.axios.delete( + `/api/v2/oauth2-provider/apps/${appId}/secrets/${secretId}`, + ); + }; + + revokeOAuth2ProviderApp = async (appId: string): Promise => { + await this.axios.delete(`/oauth2/tokens?client_id=${appId}`); + }; + + getAuditLogs = async ( + options: TypesGen.AuditLogsRequest, + ): Promise => { + const url = getURLWithSearchParams('/api/v2/audit', options); + const response = await this.axios.get(url); + return response.data; + }; + + getTemplateDAUs = async ( + templateId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}/daus`, + ); + + return response.data; + }; + + getDeploymentDAUs = async ( + // Default to user's local timezone. + // As /api/v2/insights/daus only accepts whole-number values for tz_offset + // we truncate the tz offset down to the closest hour. + offset = Math.trunc(new Date().getTimezoneOffset() / 60), + ): Promise => { + const response = await this.axios.get( + `/api/v2/insights/daus?tz_offset=${offset}`, + ); + + return response.data; + }; + + getTemplateACLAvailable = async ( + templateId: string, + options: TypesGen.UsersRequest, + ): Promise => { + const url = getURLWithSearchParams( + `/api/v2/templates/${templateId}/acl/available`, + options, + ).toString(); + + const response = await this.axios.get(url); + return response.data; + }; + + getTemplateACL = async ( + templateId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}/acl`, + ); + + return response.data; + }; + + updateTemplateACL = async ( + templateId: string, + data: TypesGen.UpdateTemplateACL, + ): Promise<{ message: string }> => { + const response = await this.axios.patch( + `/api/v2/templates/${templateId}/acl`, + data, + ); + + return response.data; + }; + + getApplicationsHost = async (): Promise => { + const response = await this.axios.get(`/api/v2/applications/host`); + return response.data; + }; + + getGroups = async (organizationId: string): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/groups`, + ); + + return response.data; + }; + + createGroup = async ( + organizationId: string, + data: TypesGen.CreateGroupRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/groups`, + data, + ); + return response.data; + }; + + getGroup = async (groupId: string): Promise => { + const response = await this.axios.get(`/api/v2/groups/${groupId}`); + return response.data; + }; + + patchGroup = async ( + groupId: string, + data: TypesGen.PatchGroupRequest, + ): Promise => { + const response = await this.axios.patch(`/api/v2/groups/${groupId}`, data); + return response.data; + }; + + addMember = async (groupId: string, userId: string) => { + return this.patchGroup(groupId, { + name: '', + add_users: [userId], + remove_users: [], + }); + }; + + removeMember = async (groupId: string, userId: string) => { + return this.patchGroup(groupId, { + name: '', + display_name: '', + add_users: [], + remove_users: [userId], + }); + }; + + deleteGroup = async (groupId: string): Promise => { + await this.axios.delete(`/api/v2/groups/${groupId}`); + }; + + getWorkspaceQuota = async ( + username: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspace-quota/${encodeURIComponent(username)}`, + ); + return response.data; + }; + + getAgentListeningPorts = async ( + agentID: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaceagents/${agentID}/listening-ports`, + ); + return response.data; + }; + + getWorkspaceAgentSharedPorts = async ( + workspaceID: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceID}/port-share`, + ); + return response.data; + }; + + upsertWorkspaceAgentSharedPort = async ( + workspaceID: string, + req: TypesGen.UpsertWorkspaceAgentPortShareRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/workspaces/${workspaceID}/port-share`, + req, + ); + return response.data; + }; + + deleteWorkspaceAgentSharedPort = async ( + workspaceID: string, + req: TypesGen.DeleteWorkspaceAgentPortShareRequest, + ): Promise => { + const response = await this.axios.delete( + `/api/v2/workspaces/${workspaceID}/port-share`, + { data: req }, + ); + + return response.data; + }; + + // getDeploymentSSHConfig is used by the VSCode-Extension. + getDeploymentSSHConfig = async (): Promise => { + const response = await this.axios.get(`/api/v2/deployment/ssh`); + return response.data; + }; + + getDeploymentConfig = async (): Promise => { + const response = await this.axios.get(`/api/v2/deployment/config`); + return response.data; + }; + + getDeploymentStats = async (): Promise => { + const response = await this.axios.get(`/api/v2/deployment/stats`); + return response.data; + }; + + getReplicas = async (): Promise => { + const response = await this.axios.get(`/api/v2/replicas`); + return response.data; + }; + + getFile = async (fileId: string): Promise => { + const response = await this.axios.get( + `/api/v2/files/${fileId}`, + { responseType: 'arraybuffer' }, + ); + + return response.data; + }; + + getWorkspaceProxyRegions = async (): Promise< + TypesGen.RegionsResponse + > => { + const response = await this.axios.get< + TypesGen.RegionsResponse + >(`/api/v2/regions`); + + return response.data; + }; + + getWorkspaceProxies = async (): Promise< + TypesGen.RegionsResponse + > => { + const response = await this.axios.get< + TypesGen.RegionsResponse + >(`/api/v2/workspaceproxies`); + + return response.data; + }; + + createWorkspaceProxy = async ( + b: TypesGen.CreateWorkspaceProxyRequest, + ): Promise => { + const response = await this.axios.post(`/api/v2/workspaceproxies`, b); + return response.data; + }; + + getAppearance = async (): Promise => { + try { + const response = await this.axios.get(`/api/v2/appearance`); + return response.data || {}; + } catch (ex) { + if (isAxiosError(ex) && ex.response?.status === 404) { + return { + application_name: '', + logo_url: '', + notification_banners: [], + service_banner: { + enabled: false, + }, + }; + } + + throw ex; + } + }; + + updateAppearance = async ( + b: TypesGen.AppearanceConfig, + ): Promise => { + const response = await this.axios.put(`/api/v2/appearance`, b); + return response.data; + }; + + getTemplateExamples = async ( + organizationId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/examples`, + ); + + return response.data; + }; + + uploadFile = async (file: File): Promise => { + const response = await this.axios.post('/api/v2/files', file, { + headers: { 'Content-Type': 'application/x-tar' }, + }); + + return response.data; + }; + + getTemplateVersionLogs = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/logs`, + ); + return response.data; + }; + + updateWorkspaceVersion = async ( + workspace: TypesGen.Workspace, + ): Promise => { + const template = await this.getTemplate(workspace.template_id); + return this.startWorkspace(workspace.id, template.active_version_id); + }; + + getWorkspaceBuildParameters = async ( + workspaceBuildId: TypesGen.WorkspaceBuild['id'], + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspacebuilds/${workspaceBuildId}/parameters`, + ); + + return response.data; + }; + + getLicenses = async (): Promise => { + const response = await this.axios.get(`/api/v2/licenses`); + return response.data; + }; + + createLicense = async ( + data: TypesGen.AddLicenseRequest, + ): Promise => { + const response = await this.axios.post(`/api/v2/licenses`, data); + return response.data; + }; + + removeLicense = async (licenseId: number): Promise => { + await this.axios.delete(`/api/v2/licenses/${licenseId}`); + }; + + /** Steps to change the workspace version + * - Get the latest template to access the latest active version + * - Get the current build parameters + * - Get the template parameters + * - Update the build parameters and check if there are missed parameters for + * the new version + * - If there are missing parameters raise an error + * - Create a build with the version and updated build parameters + */ + changeWorkspaceVersion = async ( + workspace: TypesGen.Workspace, + templateVersionId: string, + newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + ): Promise => { + const [currentBuildParameters, templateParameters] = await Promise.all([ + this.getWorkspaceBuildParameters(workspace.latest_build.id), + this.getTemplateVersionRichParameters(templateVersionId), + ]); + + const missingParameters = getMissingParameters( + currentBuildParameters, + newBuildParameters, + templateParameters, + ); + + if (missingParameters.length > 0) { + throw new MissingBuildParameters(missingParameters, templateVersionId); + } + + return this.postWorkspaceBuild(workspace.id, { + transition: 'start', + template_version_id: templateVersionId, + rich_parameter_values: newBuildParameters, + }); + }; + + /** Steps to update the workspace + * - Get the latest template to access the latest active version + * - Get the current build parameters + * - Get the template parameters + * - Update the build parameters and check if there are missed parameters for + * the newest version + * - If there are missing parameters raise an error + * - Create a build with the latest version and updated build parameters + */ + updateWorkspace = async ( + workspace: TypesGen.Workspace, + newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + ): Promise => { + const [template, oldBuildParameters] = await Promise.all([ + this.getTemplate(workspace.template_id), + this.getWorkspaceBuildParameters(workspace.latest_build.id), + ]); + + const activeVersionId = template.active_version_id; + const templateParameters = await this.getTemplateVersionRichParameters( + activeVersionId, + ); + + const missingParameters = getMissingParameters( + oldBuildParameters, + newBuildParameters, + templateParameters, + ); + + if (missingParameters.length > 0) { + throw new MissingBuildParameters(missingParameters, activeVersionId); + } + + return this.postWorkspaceBuild(workspace.id, { + transition: 'start', + template_version_id: activeVersionId, + rich_parameter_values: newBuildParameters, + }); + }; + + getWorkspaceResolveAutostart = async ( + workspaceId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceId}/resolve-autostart`, + ); + return response.data; + }; + + issueReconnectingPTYSignedToken = async ( + params: TypesGen.IssueReconnectingPTYSignedTokenRequest, + ): Promise => { + const response = await this.axios.post( + '/api/v2/applications/reconnecting-pty-signed-token', + params, + ); + + return response.data; + }; + + getWorkspaceParameters = async (workspace: TypesGen.Workspace) => { + const latestBuild = workspace.latest_build; + const [templateVersionRichParameters, buildParameters] = await Promise.all([ + this.getTemplateVersionRichParameters(latestBuild.template_version_id), + this.getWorkspaceBuildParameters(latestBuild.id), + ]); + + return { + templateVersionRichParameters, + buildParameters, + }; + }; + + getInsightsUserLatency = async ( + filters: InsightsParams, + ): Promise => { + const params = new URLSearchParams(filters); + const response = await this.axios.get( + `/api/v2/insights/user-latency?${params}`, + ); + + return response.data; + }; + + getInsightsUserActivity = async ( + filters: InsightsParams, + ): Promise => { + const params = new URLSearchParams(filters); + const response = await this.axios.get( + `/api/v2/insights/user-activity?${params}`, + ); + + return response.data; + }; + + getInsightsTemplate = async ( + params: InsightsTemplateParams, + ): Promise => { + const searchParams = new URLSearchParams(params); + const response = await this.axios.get( + `/api/v2/insights/templates?${searchParams}`, + ); + + return response.data; + }; + + getHealth = async (force: boolean = false) => { + const params = new URLSearchParams({ force: force.toString() }); + const response = await this.axios.get( + `/api/v2/debug/health?${params}`, + ); + return response.data; + }; + + getHealthSettings = async (): Promise => { + const res = await this.axios.get( + `/api/v2/debug/health/settings`, + ); + + return res.data; + }; + + updateHealthSettings = async (data: TypesGen.UpdateHealthSettings) => { + const response = await this.axios.put( + `/api/v2/debug/health/settings`, + data, + ); + + return response.data; + }; + + putFavoriteWorkspace = async (workspaceID: string) => { + await this.axios.put(`/api/v2/workspaces/${workspaceID}/favorite`); + }; + + deleteFavoriteWorkspace = async (workspaceID: string) => { + await this.axios.delete(`/api/v2/workspaces/${workspaceID}/favorite`); + }; + + getJFrogXRayScan = async (options: GetJFrogXRayScanParams) => { + const searchParams = new URLSearchParams({ + workspace_id: options.workspaceId, + agent_id: options.agentId, + }); + + try { + const res = await this.axios.get( + `/api/v2/integrations/jfrog/xray-scan?${searchParams}`, + ); + + return res.data; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + // react-query library does not allow undefined to be returned as a + // query result + return null; + } + + throw error; + } + }; +} + +// This is a hard coded CSRF token/cookie pair for local development. In prod, +// the GoLang webserver generates a random cookie with a new token for each +// document request. For local development, we don't use the Go webserver for +// static files, so this is the 'hack' to make local development work with +// remote apis. The CSRF cookie for this token is "JXm9hOUdZctWt0ZZGAy9xiS/gxMKYOThdxjjMnMUyn4=" +const csrfToken = + 'KNKvagCBEHZK7ihe2t7fj6VeJ0UyTDco1yVUJE8N06oNqxLu5Zx1vRxZbgfC0mJJgeGkVjgs08mgPbcWPBkZ1A=='; + +// Always attach CSRF token to all requests. In puppeteer the document is +// undefined. In those cases, just do nothing. +const tokenMetadataElement = + typeof document !== 'undefined' + ? document.head.querySelector('meta[property="csrf-token"]') + : null; + +function getConfiguredAxiosInstance(): AxiosInstance { + const instance = globalAxios.create(); + + // Adds 304 for the default axios validateStatus function + // https://github.com/axios/axios#handling-errors Check status here + // https://httpstatusdogs.com/ + instance.defaults.validateStatus = status => { + return (status >= 200 && status < 300) || status === 304; + }; + + const metadataIsAvailable = + tokenMetadataElement !== null && + tokenMetadataElement.getAttribute('content') !== null; + + if (metadataIsAvailable) { + if (process.env.NODE_ENV === 'development') { + // Development mode uses a hard-coded CSRF token + instance.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken; + instance.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken; + tokenMetadataElement.setAttribute('content', csrfToken); + } else { + instance.defaults.headers.common['X-CSRF-TOKEN'] = + tokenMetadataElement.getAttribute('content') ?? ''; + } + } else { + // Do not write error logs if we are in a FE unit test. + if (process.env.JEST_WORKER_ID === undefined) { + // eslint-disable-next-line no-console -- Function should never run in vendored version of SDK + console.error('CSRF token not found'); + } + } + + return instance; +} + +// Other non-API methods defined here to make it a little easier to find them. +interface ClientApi extends ApiMethods { + getCsrfToken: () => string; + setSessionToken: (token: string) => void; + setHost: (host: string | undefined) => void; + getAxiosInstance: () => AxiosInstance; +} + +export class Api extends ApiMethods implements ClientApi { + constructor() { + const scopedAxiosInstance = getConfiguredAxiosInstance(); + super(scopedAxiosInstance); + } + + // As with ApiMethods, all public methods should be defined with arrow + // function syntax to ensure they can be passed around the React UI without + // losing/detaching their `this` context! + + getCsrfToken = (): string => { + return csrfToken; + }; + + setSessionToken = (token: string): void => { + this.axios.defaults.headers.common['Coder-Session-Token'] = token; + }; + + setHost = (host: string | undefined): void => { + this.axios.defaults.baseURL = host; + }; + + getAxiosInstance = (): AxiosInstance => { + return this.axios; + }; +} + +export const API = new Api(); diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts new file mode 100644 index 00000000..6d401a11 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts @@ -0,0 +1,124 @@ +import { type AxiosError, type AxiosResponse, isAxiosError } from 'axios'; + +const Language = { + errorsByCode: { + defaultErrorCode: 'Invalid value', + }, +}; + +export interface FieldError { + field: string; + detail: string; +} + +export type FieldErrors = Record; + +export interface ApiErrorResponse { + message: string; + detail?: string; + validations?: FieldError[]; +} + +export type ApiError = AxiosError & { + response: AxiosResponse; +}; + +export const isApiErrorResponse = (err: unknown): err is ApiErrorResponse => { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof err.message === 'string' && + (!('detail' in err) || + err.detail === undefined || + typeof err.detail === 'string') && + (!('validations' in err) || + err.validations === undefined || + Array.isArray(err.validations)) + ); +}; + +export const isApiError = (err: unknown): err is ApiError => { + return ( + isAxiosError(err) && + err.response !== undefined && + isApiErrorResponse(err.response.data) + ); +}; + +export const hasApiFieldErrors = (error: ApiError): boolean => + Array.isArray(error.response.data.validations); + +export const isApiValidationError = (error: unknown): error is ApiError => { + return isApiError(error) && hasApiFieldErrors(error); +}; + +export const hasError = (error: unknown) => + error !== undefined && error !== null; + +export const mapApiErrorToFieldErrors = ( + apiErrorResponse: ApiErrorResponse, +): FieldErrors => { + const result: FieldErrors = {}; + + if (apiErrorResponse.validations) { + for (const error of apiErrorResponse.validations) { + result[error.field] = + error.detail || Language.errorsByCode.defaultErrorCode; + } + } + + return result; +}; + +/** + * + * @param error + * @param defaultMessage + * @returns error's message if ApiError or Error, else defaultMessage + */ +export const getErrorMessage = ( + error: unknown, + defaultMessage: string, +): string => { + // if error is API error + // 404s result in the default message being returned + if (isApiError(error) && error.response.data.message) { + return error.response.data.message; + } + if (isApiErrorResponse(error)) { + return error.message; + } + // if error is a non-empty string + if (error && typeof error === 'string') { + return error; + } + return defaultMessage; +}; + +/** + * + * @param error + * @returns a combined validation error message if the error is an ApiError + * and contains validation messages for different form fields. + */ +export const getValidationErrorMessage = (error: unknown): string => { + const validationErrors = + isApiError(error) && error.response.data.validations + ? error.response.data.validations + : []; + return validationErrors.map(error => error.detail).join('\n'); +}; + +export const getErrorDetail = (error: unknown): string | undefined | null => { + if (error instanceof Error) { + return 'Please check the developer console for more details.'; + } + if (isApiError(error)) { + return error.response.data.detail; + } + if (isApiErrorResponse(error)) { + return error.detail; + } + return null; +}; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts new file mode 100644 index 00000000..2e3b4f04 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts @@ -0,0 +1,2599 @@ +// Code generated by 'make site/src/api/typesGenerated.ts'. DO NOT EDIT. + +// The code below is generated from codersdk. + +// From codersdk/templates.go +export interface ACLAvailable { + readonly users: readonly ReducedUser[]; + readonly groups: readonly Group[]; +} + +// From codersdk/apikey.go +export interface APIKey { + readonly id: string; + readonly user_id: string; + readonly last_used: string; + readonly expires_at: string; + readonly created_at: string; + readonly updated_at: string; + readonly login_type: LoginType; + readonly scope: APIKeyScope; + readonly token_name: string; + readonly lifetime_seconds: number; +} + +// From codersdk/apikey.go +export interface APIKeyWithOwner extends APIKey { + readonly username: string; +} + +// From codersdk/licenses.go +export interface AddLicenseRequest { + readonly license: string; +} + +// From codersdk/templates.go +export interface AgentStatsReportResponse { + readonly num_comms: number; + readonly rx_bytes: number; + readonly tx_bytes: number; +} + +// From codersdk/deployment.go +export interface AppHostResponse { + readonly host: string; +} + +// From codersdk/deployment.go +export interface AppearanceConfig { + readonly application_name: string; + readonly logo_url: string; + readonly service_banner: BannerConfig; + readonly notification_banners: readonly BannerConfig[]; + readonly support_links?: readonly LinkConfig[]; +} + +// From codersdk/templates.go +export interface ArchiveTemplateVersionsRequest { + readonly all: boolean; +} + +// From codersdk/templates.go +export interface ArchiveTemplateVersionsResponse { + readonly template_id: string; + readonly archived_ids: readonly string[]; +} + +// From codersdk/roles.go +export interface AssignableRoles extends Role { + readonly assignable: boolean; + readonly built_in: boolean; +} + +// From codersdk/audit.go +export type AuditDiff = Record; + +// From codersdk/audit.go +export interface AuditDiffField { + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly old?: any; + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly new?: any; + readonly secret: boolean; +} + +// From codersdk/audit.go +export interface AuditLog { + readonly id: string; + readonly request_id: string; + readonly time: string; + readonly organization_id: string; + // Named type "net/netip.Addr" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly ip: any; + readonly user_agent: string; + readonly resource_type: ResourceType; + readonly resource_id: string; + readonly resource_target: string; + readonly resource_icon: string; + readonly action: AuditAction; + readonly diff: AuditDiff; + readonly status_code: number; + readonly additional_fields: Record; + readonly description: string; + readonly resource_link: string; + readonly is_deleted: boolean; + readonly user?: User; +} + +// From codersdk/audit.go +export interface AuditLogResponse { + readonly audit_logs: readonly AuditLog[]; + readonly count: number; +} + +// From codersdk/audit.go +export interface AuditLogsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/users.go +export interface AuthMethod { + readonly enabled: boolean; +} + +// From codersdk/users.go +export interface AuthMethods { + readonly terms_of_service_url?: string; + readonly password: AuthMethod; + readonly github: AuthMethod; + readonly oidc: OIDCAuthMethod; +} + +// From codersdk/authorization.go +export interface AuthorizationCheck { + readonly object: AuthorizationObject; + readonly action: RBACAction; +} + +// From codersdk/authorization.go +export interface AuthorizationObject { + readonly resource_type: RBACResource; + readonly owner_id?: string; + readonly organization_id?: string; + readonly resource_id?: string; +} + +// From codersdk/authorization.go +export interface AuthorizationRequest { + readonly checks: Record; +} + +// From codersdk/authorization.go +export type AuthorizationResponse = Record; + +// From codersdk/deployment.go +export interface AvailableExperiments { + readonly safe: readonly Experiment[]; +} + +// From codersdk/deployment.go +export interface BannerConfig { + readonly enabled: boolean; + readonly message?: string; + readonly background_color?: string; +} + +// From codersdk/deployment.go +export interface BuildInfoResponse { + readonly external_url: string; + readonly version: string; + readonly dashboard_url: string; + readonly workspace_proxy: boolean; + readonly agent_api_version: string; + readonly upgrade_message: string; + readonly deployment_id: string; +} + +// From codersdk/insights.go +export interface ConnectionLatency { + readonly p50: number; + readonly p95: number; +} + +// From codersdk/users.go +export interface ConvertLoginRequest { + readonly to_type: LoginType; + readonly password: string; +} + +// From codersdk/users.go +export interface CreateFirstUserRequest { + readonly email: string; + readonly username: string; + readonly password: string; + readonly trial: boolean; + readonly trial_info: CreateFirstUserTrialInfo; +} + +// From codersdk/users.go +export interface CreateFirstUserResponse { + readonly user_id: string; + readonly organization_id: string; +} + +// From codersdk/users.go +export interface CreateFirstUserTrialInfo { + readonly first_name: string; + readonly last_name: string; + readonly phone_number: string; + readonly job_title: string; + readonly company_name: string; + readonly country: string; + readonly developers: string; +} + +// From codersdk/groups.go +export interface CreateGroupRequest { + readonly name: string; + readonly display_name: string; + readonly avatar_url: string; + readonly quota_allowance: number; +} + +// From codersdk/organizations.go +export interface CreateOrganizationRequest { + readonly name: string; +} + +// From codersdk/organizations.go +export interface CreateTemplateRequest { + readonly name: string; + readonly display_name?: string; + readonly description?: string; + readonly icon?: string; + readonly template_version_id: string; + readonly default_ttl_ms?: number; + readonly activity_bump_ms?: number; + readonly autostop_requirement?: TemplateAutostopRequirement; + readonly autostart_requirement?: TemplateAutostartRequirement; + readonly allow_user_cancel_workspace_jobs?: boolean; + readonly allow_user_autostart?: boolean; + readonly allow_user_autostop?: boolean; + readonly failure_ttl_ms?: number; + readonly dormant_ttl_ms?: number; + readonly delete_ttl_ms?: number; + readonly disable_everyone_group_access: boolean; + readonly require_active_version: boolean; +} + +// From codersdk/templateversions.go +export interface CreateTemplateVersionDryRunRequest { + readonly workspace_name: string; + readonly rich_parameter_values: readonly WorkspaceBuildParameter[]; + readonly user_variable_values?: readonly VariableValue[]; +} + +// From codersdk/organizations.go +export interface CreateTemplateVersionRequest { + readonly name?: string; + readonly message?: string; + readonly template_id?: string; + readonly storage_method: ProvisionerStorageMethod; + readonly file_id?: string; + readonly example_id?: string; + readonly provisioner: ProvisionerType; + readonly tags: Record; + readonly user_variable_values?: readonly VariableValue[]; +} + +// From codersdk/audit.go +export interface CreateTestAuditLogRequest { + readonly action?: AuditAction; + readonly resource_type?: ResourceType; + readonly resource_id?: string; + readonly additional_fields?: Record; + readonly time?: string; + readonly build_reason?: BuildReason; +} + +// From codersdk/apikey.go +export interface CreateTokenRequest { + readonly lifetime: number; + readonly scope: APIKeyScope; + readonly token_name: string; +} + +// From codersdk/users.go +export interface CreateUserRequest { + readonly email: string; + readonly username: string; + readonly password: string; + readonly login_type: LoginType; + readonly disable_login: boolean; + readonly organization_id: string; +} + +// From codersdk/workspaces.go +export interface CreateWorkspaceBuildRequest { + readonly template_version_id?: string; + readonly transition: WorkspaceTransition; + readonly dry_run?: boolean; + readonly state?: string; + readonly orphan?: boolean; + readonly rich_parameter_values?: readonly WorkspaceBuildParameter[]; + readonly log_level?: ProvisionerLogLevel; +} + +// From codersdk/workspaceproxy.go +export interface CreateWorkspaceProxyRequest { + readonly name: string; + readonly display_name: string; + readonly icon: string; +} + +// From codersdk/organizations.go +export interface CreateWorkspaceRequest { + readonly template_id?: string; + readonly template_version_id?: string; + readonly name: string; + readonly autostart_schedule?: string; + readonly ttl_ms?: number; + readonly rich_parameter_values?: readonly WorkspaceBuildParameter[]; + readonly automatic_updates?: AutomaticUpdates; +} + +// From codersdk/deployment.go +export interface DAUEntry { + readonly date: string; + readonly amount: number; +} + +// From codersdk/deployment.go +export interface DAURequest { + readonly TZHourOffset: number; +} + +// From codersdk/deployment.go +export interface DAUsResponse { + readonly entries: readonly DAUEntry[]; + readonly tz_hour_offset: number; +} + +// From codersdk/deployment.go +export interface DERP { + readonly server: DERPServerConfig; + readonly config: DERPConfig; +} + +// From codersdk/deployment.go +export interface DERPConfig { + readonly block_direct: boolean; + readonly force_websockets: boolean; + readonly url: string; + readonly path: string; +} + +// From codersdk/workspaceagents.go +export interface DERPRegion { + readonly preferred: boolean; + readonly latency_ms: number; +} + +// From codersdk/deployment.go +export interface DERPServerConfig { + readonly enable: boolean; + readonly region_id: number; + readonly region_code: string; + readonly region_name: string; + readonly stun_addresses: string[]; + readonly relay_url: string; +} + +// From codersdk/deployment.go +export interface DangerousConfig { + readonly allow_path_app_sharing: boolean; + readonly allow_path_app_site_owner_access: boolean; + readonly allow_all_cors: boolean; +} + +// From codersdk/workspaceagentportshare.go +export interface DeleteWorkspaceAgentPortShareRequest { + readonly agent_name: string; + readonly port: number; +} + +// From codersdk/deployment.go +export interface DeploymentConfig { + readonly config?: DeploymentValues; + readonly options?: SerpentOptionSet; +} + +// From codersdk/deployment.go +export interface DeploymentStats { + readonly aggregated_from: string; + readonly collected_at: string; + readonly next_update_at: string; + readonly workspaces: WorkspaceDeploymentStats; + readonly session_count: SessionCountDeploymentStats; +} + +// From codersdk/deployment.go +export interface DeploymentValues { + readonly verbose?: boolean; + readonly access_url?: string; + readonly wildcard_access_url?: string; + readonly docs_url?: string; + readonly redirect_to_access_url?: boolean; + readonly http_address?: string; + readonly autobuild_poll_interval?: number; + readonly job_hang_detector_interval?: number; + readonly derp?: DERP; + readonly prometheus?: PrometheusConfig; + readonly pprof?: PprofConfig; + readonly proxy_trusted_headers?: string[]; + readonly proxy_trusted_origins?: string[]; + readonly cache_directory?: string; + readonly in_memory_database?: boolean; + readonly pg_connection_url?: string; + readonly pg_auth?: string; + readonly oauth2?: OAuth2Config; + readonly oidc?: OIDCConfig; + readonly telemetry?: TelemetryConfig; + readonly tls?: TLSConfig; + readonly trace?: TraceConfig; + readonly secure_auth_cookie?: boolean; + readonly strict_transport_security?: number; + readonly strict_transport_security_options?: string[]; + readonly ssh_keygen_algorithm?: string; + readonly metrics_cache_refresh_interval?: number; + readonly agent_stat_refresh_interval?: number; + readonly agent_fallback_troubleshooting_url?: string; + readonly browser_only?: boolean; + readonly scim_api_key?: string; + readonly external_token_encryption_keys?: string[]; + readonly provisioner?: ProvisionerConfig; + readonly rate_limit?: RateLimitConfig; + readonly experiments?: string[]; + readonly update_check?: boolean; + readonly swagger?: SwaggerConfig; + readonly logging?: LoggingConfig; + readonly dangerous?: DangerousConfig; + readonly disable_path_apps?: boolean; + readonly session_lifetime?: SessionLifetime; + readonly disable_password_auth?: boolean; + readonly support?: SupportConfig; + readonly external_auth?: readonly ExternalAuthConfig[]; + readonly config_ssh?: SSHConfig; + readonly wgtunnel_host?: string; + readonly disable_owner_workspace_exec?: boolean; + readonly proxy_health_status_interval?: number; + readonly enable_terraform_debug_mode?: boolean; + readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig; + readonly web_terminal_renderer?: string; + readonly allow_workspace_renames?: boolean; + readonly healthcheck?: HealthcheckConfig; + readonly cli_upgrade_message?: string; + readonly terms_of_service_url?: string; + readonly config?: string; + readonly write_config?: boolean; + readonly address?: string; +} + +// From codersdk/deployment.go +export interface Entitlements { + readonly features: Record; + readonly warnings: readonly string[]; + readonly errors: readonly string[]; + readonly has_license: boolean; + readonly trial: boolean; + readonly require_telemetry: boolean; + readonly refreshed_at: string; +} + +// From codersdk/deployment.go +export type Experiments = readonly Experiment[]; + +// From codersdk/externalauth.go +export interface ExternalAuth { + readonly authenticated: boolean; + readonly device: boolean; + readonly display_name: string; + readonly user?: ExternalAuthUser; + readonly app_installable: boolean; + readonly installations: readonly ExternalAuthAppInstallation[]; + readonly app_install_url: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthAppInstallation { + readonly id: number; + readonly account: ExternalAuthUser; + readonly configure_url: string; +} + +// From codersdk/deployment.go +export interface ExternalAuthConfig { + readonly type: string; + readonly client_id: string; + readonly id: string; + readonly auth_url: string; + readonly token_url: string; + readonly validate_url: string; + readonly app_install_url: string; + readonly app_installations_url: string; + readonly no_refresh: boolean; + readonly scopes: readonly string[]; + readonly extra_token_keys: readonly string[]; + readonly device_flow: boolean; + readonly device_code_url: string; + readonly regex: string; + readonly display_name: string; + readonly display_icon: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthDevice { + readonly device_code: string; + readonly user_code: string; + readonly verification_uri: string; + readonly expires_in: number; + readonly interval: number; +} + +// From codersdk/externalauth.go +export interface ExternalAuthDeviceExchange { + readonly device_code: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthLink { + readonly provider_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly has_refresh_token: boolean; + readonly expires: string; + readonly authenticated: boolean; + readonly validate_error: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthLinkProvider { + readonly id: string; + readonly type: string; + readonly device: boolean; + readonly display_name: string; + readonly display_icon: string; + readonly allow_refresh: boolean; + readonly allow_validate: boolean; +} + +// From codersdk/externalauth.go +export interface ExternalAuthUser { + readonly login: string; + readonly avatar_url: string; + readonly profile_url: string; + readonly name: string; +} + +// From codersdk/deployment.go +export interface Feature { + readonly entitlement: Entitlement; + readonly enabled: boolean; + readonly limit?: number; + readonly actual?: number; +} + +// From codersdk/apikey.go +export interface GenerateAPIKeyResponse { + readonly key: string; +} + +// From codersdk/users.go +export interface GetUsersResponse { + readonly users: readonly User[]; + readonly count: number; +} + +// From codersdk/gitsshkey.go +export interface GitSSHKey { + readonly user_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly public_key: string; +} + +// From codersdk/groups.go +export interface Group { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly organization_id: string; + readonly members: readonly ReducedUser[]; + readonly avatar_url: string; + readonly quota_allowance: number; + readonly source: GroupSource; +} + +// From codersdk/workspaceapps.go +export interface Healthcheck { + readonly url: string; + readonly interval: number; + readonly threshold: number; +} + +// From codersdk/deployment.go +export interface HealthcheckConfig { + readonly refresh: number; + readonly threshold_database: number; +} + +// From codersdk/workspaceagents.go +export interface IssueReconnectingPTYSignedTokenRequest { + readonly url: string; + readonly agentID: string; +} + +// From codersdk/workspaceagents.go +export interface IssueReconnectingPTYSignedTokenResponse { + readonly signed_token: string; +} + +// From codersdk/jfrog.go +export interface JFrogXrayScan { + readonly workspace_id: string; + readonly agent_id: string; + readonly critical: number; + readonly high: number; + readonly medium: number; + readonly results_url: string; +} + +// From codersdk/licenses.go +export interface License { + readonly id: number; + readonly uuid: string; + readonly uploaded_at: string; + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly claims: Record; +} + +// From codersdk/deployment.go +export interface LinkConfig { + readonly name: string; + readonly target: string; + readonly icon: string; +} + +// From codersdk/externalauth.go +export interface ListUserExternalAuthResponse { + readonly providers: readonly ExternalAuthLinkProvider[]; + readonly links: readonly ExternalAuthLink[]; +} + +// From codersdk/deployment.go +export interface LoggingConfig { + readonly log_filter: string[]; + readonly human: string; + readonly json: string; + readonly stackdriver: string; +} + +// From codersdk/users.go +export interface LoginWithPasswordRequest { + readonly email: string; + readonly password: string; +} + +// From codersdk/users.go +export interface LoginWithPasswordResponse { + readonly session_token: string; +} + +// From codersdk/users.go +export interface MinimalUser { + readonly id: string; + readonly username: string; + readonly avatar_url: string; +} + +// From codersdk/oauth2.go +export interface OAuth2AppEndpoints { + readonly authorization: string; + readonly token: string; + readonly device_authorization: string; +} + +// From codersdk/deployment.go +export interface OAuth2Config { + readonly github: OAuth2GithubConfig; +} + +// From codersdk/deployment.go +export interface OAuth2GithubConfig { + readonly client_id: string; + readonly client_secret: string; + readonly allowed_orgs: string[]; + readonly allowed_teams: string[]; + readonly allow_signups: boolean; + readonly allow_everyone: boolean; + readonly enterprise_base_url: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderApp { + readonly id: string; + readonly name: string; + readonly callback_url: string; + readonly icon: string; + readonly endpoints: OAuth2AppEndpoints; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppFilter { + readonly user_id?: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppSecret { + readonly id: string; + readonly last_used_at?: string; + readonly client_secret_truncated: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppSecretFull { + readonly id: string; + readonly client_secret_full: string; +} + +// From codersdk/users.go +export interface OAuthConversionResponse { + readonly state_string: string; + readonly expires_at: string; + readonly to_type: LoginType; + readonly user_id: string; +} + +// From codersdk/users.go +export interface OIDCAuthMethod extends AuthMethod { + readonly signInText: string; + readonly iconUrl: string; +} + +// From codersdk/deployment.go +export interface OIDCConfig { + readonly allow_signups: boolean; + readonly client_id: string; + readonly client_secret: string; + readonly client_key_file: string; + readonly client_cert_file: string; + readonly email_domain: string[]; + readonly issuer_url: string; + readonly scopes: string[]; + readonly ignore_email_verified: boolean; + readonly username_field: string; + readonly email_field: string; + readonly auth_url_params: Record; + readonly ignore_user_info: boolean; + readonly group_auto_create: boolean; + readonly group_regex_filter: string; + readonly group_allow_list: string[]; + readonly groups_field: string; + readonly group_mapping: Record; + readonly user_role_field: string; + readonly user_role_mapping: Record; + readonly user_roles_default: string[]; + readonly sign_in_text: string; + readonly icon_url: string; + readonly signups_disabled_text: string; +} + +// From codersdk/organizations.go +export interface Organization { + readonly id: string; + readonly name: string; + readonly created_at: string; + readonly updated_at: string; + readonly is_default: boolean; +} + +// From codersdk/organizations.go +export interface OrganizationMember { + readonly user_id: string; + readonly organization_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly roles: readonly SlimRole[]; +} + +// From codersdk/pagination.go +export interface Pagination { + readonly after_id?: string; + readonly limit?: number; + readonly offset?: number; +} + +// From codersdk/groups.go +export interface PatchGroupRequest { + readonly add_users: readonly string[]; + readonly remove_users: readonly string[]; + readonly name: string; + readonly display_name?: string; + readonly avatar_url?: string; + readonly quota_allowance?: number; +} + +// From codersdk/templateversions.go +export interface PatchTemplateVersionRequest { + readonly name: string; + readonly message?: string; +} + +// From codersdk/workspaceproxy.go +export interface PatchWorkspaceProxy { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly icon: string; + readonly regenerate_token: boolean; +} + +// From codersdk/roles.go +export interface Permission { + readonly negate: boolean; + readonly resource_type: RBACResource; + readonly action: RBACAction; +} + +// From codersdk/oauth2.go +export interface PostOAuth2ProviderAppRequest { + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + +// From codersdk/deployment.go +export interface PprofConfig { + readonly enable: boolean; + readonly address: string; +} + +// From codersdk/deployment.go +export interface PrometheusConfig { + readonly enable: boolean; + readonly address: string; + readonly collect_agent_stats: boolean; + readonly collect_db_metrics: boolean; + readonly aggregate_agent_stats_by: string[]; +} + +// From codersdk/deployment.go +export interface ProvisionerConfig { + readonly daemons: number; + readonly daemon_types: string[]; + readonly daemon_poll_interval: number; + readonly daemon_poll_jitter: number; + readonly force_cancel_interval: number; + readonly daemon_psk: string; +} + +// From codersdk/provisionerdaemons.go +export interface ProvisionerDaemon { + readonly id: string; + readonly created_at: string; + readonly last_seen_at?: string; + readonly name: string; + readonly version: string; + readonly api_version: string; + readonly provisioners: readonly ProvisionerType[]; + readonly tags: Record; +} + +// From codersdk/provisionerdaemons.go +export interface ProvisionerJob { + readonly id: string; + readonly created_at: string; + readonly started_at?: string; + readonly completed_at?: string; + readonly canceled_at?: string; + readonly error?: string; + readonly error_code?: JobErrorCode; + readonly status: ProvisionerJobStatus; + readonly worker_id?: string; + readonly file_id: string; + readonly tags: Record; + readonly queue_position: number; + readonly queue_size: number; +} + +// From codersdk/provisionerdaemons.go +export interface ProvisionerJobLog { + readonly id: number; + readonly created_at: string; + readonly log_source: LogSource; + readonly log_level: LogLevel; + readonly stage: string; + readonly output: string; +} + +// From codersdk/workspaceproxy.go +export interface ProxyHealthReport { + readonly errors: readonly string[]; + readonly warnings: readonly string[]; +} + +// From codersdk/workspaces.go +export interface PutExtendWorkspaceRequest { + readonly deadline: string; +} + +// From codersdk/oauth2.go +export interface PutOAuth2ProviderAppRequest { + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + +// From codersdk/deployment.go +export interface RateLimitConfig { + readonly disable_all: boolean; + readonly api: number; +} + +// From codersdk/users.go +export interface ReducedUser extends MinimalUser { + readonly name: string; + readonly email: string; + readonly created_at: string; + readonly last_seen_at: string; + readonly status: UserStatus; + readonly login_type: LoginType; + readonly theme_preference: string; +} + +// From codersdk/workspaceproxy.go +export interface Region { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly icon_url: string; + readonly healthy: boolean; + readonly path_app_url: string; + readonly wildcard_hostname: string; +} + +// From codersdk/workspaceproxy.go +export interface RegionsResponse { + readonly regions: readonly R[]; +} + +// From codersdk/replicas.go +export interface Replica { + readonly id: string; + readonly hostname: string; + readonly created_at: string; + readonly relay_address: string; + readonly region_id: number; + readonly error: string; + readonly database_latency: number; +} + +// From codersdk/workspaces.go +export interface ResolveAutostartResponse { + readonly parameter_mismatch: boolean; +} + +// From codersdk/client.go +export interface Response { + readonly message: string; + readonly detail?: string; + readonly validations?: readonly ValidationError[]; +} + +// From codersdk/roles.go +export interface Role { + readonly name: string; + readonly organization_id: string; + readonly display_name: string; + readonly site_permissions: readonly Permission[]; + readonly organization_permissions: Record; + readonly user_permissions: readonly Permission[]; +} + +// From codersdk/deployment.go +export interface SSHConfig { + readonly DeploymentName: string; + readonly SSHConfigOptions: string[]; +} + +// From codersdk/deployment.go +export interface SSHConfigResponse { + readonly hostname_prefix: string; + readonly ssh_config_options: Record; +} + +// From codersdk/serversentevents.go +export interface ServerSentEvent { + readonly type: ServerSentEventType; + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly data: any; +} + +// From codersdk/deployment.go +export interface ServiceBannerConfig { + readonly enabled: boolean; + readonly message?: string; + readonly background_color?: string; +} + +// From codersdk/deployment.go +export interface SessionCountDeploymentStats { + readonly vscode: number; + readonly ssh: number; + readonly jetbrains: number; + readonly reconnecting_pty: number; +} + +// From codersdk/deployment.go +export interface SessionLifetime { + readonly disable_expiry_refresh?: boolean; + readonly default_duration: number; + readonly max_token_lifetime?: number; +} + +// From codersdk/roles.go +export interface SlimRole { + readonly name: string; + readonly display_name: string; +} + +// From codersdk/deployment.go +export interface SupportConfig { + readonly links: readonly LinkConfig[]; +} + +// From codersdk/deployment.go +export interface SwaggerConfig { + readonly enable: boolean; +} + +// From codersdk/deployment.go +export interface TLSConfig { + readonly enable: boolean; + readonly address: string; + readonly redirect_http: boolean; + readonly cert_file: string[]; + readonly client_auth: string; + readonly client_ca_file: string; + readonly key_file: string[]; + readonly min_version: string; + readonly client_cert_file: string; + readonly client_key_file: string; + readonly supported_ciphers: string[]; + readonly allow_insecure_ciphers: boolean; +} + +// From codersdk/deployment.go +export interface TelemetryConfig { + readonly enable: boolean; + readonly trace: boolean; + readonly url: string; +} + +// From codersdk/templates.go +export interface Template { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly organization_id: string; + readonly name: string; + readonly display_name: string; + readonly provisioner: ProvisionerType; + readonly active_version_id: string; + readonly active_user_count: number; + readonly build_time_stats: TemplateBuildTimeStats; + readonly description: string; + readonly deprecated: boolean; + readonly deprecation_message: string; + readonly icon: string; + readonly default_ttl_ms: number; + readonly activity_bump_ms: number; + readonly autostop_requirement: TemplateAutostopRequirement; + readonly autostart_requirement: TemplateAutostartRequirement; + readonly created_by_id: string; + readonly created_by_name: string; + readonly allow_user_autostart: boolean; + readonly allow_user_autostop: boolean; + readonly allow_user_cancel_workspace_jobs: boolean; + readonly failure_ttl_ms: number; + readonly time_til_dormant_ms: number; + readonly time_til_dormant_autodelete_ms: number; + readonly require_active_version: boolean; + readonly max_port_share_level: WorkspaceAgentPortShareLevel; +} + +// From codersdk/templates.go +export interface TemplateACL { + readonly users: readonly TemplateUser[]; + readonly group: readonly TemplateGroup[]; +} + +// From codersdk/insights.go +export interface TemplateAppUsage { + readonly template_ids: readonly string[]; + readonly type: TemplateAppsType; + readonly display_name: string; + readonly slug: string; + readonly icon: string; + readonly seconds: number; + readonly times_used: number; +} + +// From codersdk/templates.go +export interface TemplateAutostartRequirement { + readonly days_of_week: readonly string[]; +} + +// From codersdk/templates.go +export interface TemplateAutostopRequirement { + readonly days_of_week: readonly string[]; + readonly weeks: number; +} + +// From codersdk/templates.go +export type TemplateBuildTimeStats = Record< + WorkspaceTransition, + TransitionStats +>; + +// From codersdk/templates.go +export interface TemplateExample { + readonly id: string; + readonly url: string; + readonly name: string; + readonly description: string; + readonly icon: string; + readonly tags: readonly string[]; + readonly markdown: string; +} + +// From codersdk/templates.go +export interface TemplateGroup extends Group { + readonly role: TemplateRole; +} + +// From codersdk/insights.go +export interface TemplateInsightsIntervalReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly interval: InsightsReportInterval; + readonly active_users: number; +} + +// From codersdk/insights.go +export interface TemplateInsightsReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly active_users: number; + readonly apps_usage: readonly TemplateAppUsage[]; + readonly parameters_usage: readonly TemplateParameterUsage[]; +} + +// From codersdk/insights.go +export interface TemplateInsightsRequest { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly interval: InsightsReportInterval; + readonly sections: readonly TemplateInsightsSection[]; +} + +// From codersdk/insights.go +export interface TemplateInsightsResponse { + readonly report?: TemplateInsightsReport; + readonly interval_reports?: readonly TemplateInsightsIntervalReport[]; +} + +// From codersdk/insights.go +export interface TemplateParameterUsage { + readonly template_ids: readonly string[]; + readonly display_name: string; + readonly name: string; + readonly type: string; + readonly description: string; + readonly options?: readonly TemplateVersionParameterOption[]; + readonly values: readonly TemplateParameterValue[]; +} + +// From codersdk/insights.go +export interface TemplateParameterValue { + readonly value: string; + readonly count: number; +} + +// From codersdk/templates.go +export interface TemplateUser extends User { + readonly role: TemplateRole; +} + +// From codersdk/templateversions.go +export interface TemplateVersion { + readonly id: string; + readonly template_id?: string; + readonly organization_id?: string; + readonly created_at: string; + readonly updated_at: string; + readonly name: string; + readonly message: string; + readonly job: ProvisionerJob; + readonly readme: string; + readonly created_by: MinimalUser; + readonly archived: boolean; + readonly warnings?: readonly TemplateVersionWarning[]; +} + +// From codersdk/templateversions.go +export interface TemplateVersionExternalAuth { + readonly id: string; + readonly type: string; + readonly display_name: string; + readonly display_icon: string; + readonly authenticate_url: string; + readonly authenticated: boolean; + readonly optional?: boolean; +} + +// From codersdk/templateversions.go +export interface TemplateVersionParameter { + readonly name: string; + readonly display_name?: string; + readonly description: string; + readonly description_plaintext: string; + readonly type: string; + readonly mutable: boolean; + readonly default_value: string; + readonly icon: string; + readonly options: readonly TemplateVersionParameterOption[]; + readonly validation_error?: string; + readonly validation_regex?: string; + readonly validation_min?: number; + readonly validation_max?: number; + readonly validation_monotonic?: ValidationMonotonicOrder; + readonly required: boolean; + readonly ephemeral: boolean; +} + +// From codersdk/templateversions.go +export interface TemplateVersionParameterOption { + readonly name: string; + readonly description: string; + readonly value: string; + readonly icon: string; +} + +// From codersdk/templateversions.go +export interface TemplateVersionVariable { + readonly name: string; + readonly description: string; + readonly type: string; + readonly value: string; + readonly default_value: string; + readonly required: boolean; + readonly sensitive: boolean; +} + +// From codersdk/templates.go +export interface TemplateVersionsByTemplateRequest extends Pagination { + readonly template_id: string; + readonly include_archived: boolean; +} + +// From codersdk/apikey.go +export interface TokenConfig { + readonly max_token_lifetime: number; +} + +// From codersdk/apikey.go +export interface TokensFilter { + readonly include_all: boolean; +} + +// From codersdk/deployment.go +export interface TraceConfig { + readonly enable: boolean; + readonly honeycomb_api_key: string; + readonly capture_logs: boolean; + readonly data_dog: boolean; +} + +// From codersdk/templates.go +export interface TransitionStats { + readonly P50?: number; + readonly P95?: number; +} + +// From codersdk/templates.go +export interface UpdateActiveTemplateVersion { + readonly id: string; +} + +// From codersdk/deployment.go +export interface UpdateAppearanceConfig { + readonly application_name: string; + readonly logo_url: string; + readonly service_banner: BannerConfig; + readonly notification_banners: readonly BannerConfig[]; +} + +// From codersdk/updatecheck.go +export interface UpdateCheckResponse { + readonly current: boolean; + readonly version: string; + readonly url: string; +} + +// From codersdk/organizations.go +export interface UpdateOrganizationRequest { + readonly name: string; +} + +// From codersdk/users.go +export interface UpdateRoles { + readonly roles: readonly string[]; +} + +// From codersdk/templates.go +export interface UpdateTemplateACL { + readonly user_perms?: Record; + readonly group_perms?: Record; +} + +// From codersdk/templates.go +export interface UpdateTemplateMeta { + readonly name?: string; + readonly display_name?: string; + readonly description?: string; + readonly icon?: string; + readonly default_ttl_ms?: number; + readonly activity_bump_ms?: number; + readonly autostop_requirement?: TemplateAutostopRequirement; + readonly autostart_requirement?: TemplateAutostartRequirement; + readonly allow_user_autostart?: boolean; + readonly allow_user_autostop?: boolean; + readonly allow_user_cancel_workspace_jobs?: boolean; + readonly failure_ttl_ms?: number; + readonly time_til_dormant_ms?: number; + readonly time_til_dormant_autodelete_ms?: number; + readonly update_workspace_last_used_at: boolean; + readonly update_workspace_dormant_at: boolean; + readonly require_active_version?: boolean; + readonly deprecation_message?: string; + readonly disable_everyone_group_access: boolean; + readonly max_port_share_level?: WorkspaceAgentPortShareLevel; +} + +// From codersdk/users.go +export interface UpdateUserAppearanceSettingsRequest { + readonly theme_preference: string; +} + +// From codersdk/users.go +export interface UpdateUserPasswordRequest { + readonly old_password: string; + readonly password: string; +} + +// From codersdk/users.go +export interface UpdateUserProfileRequest { + readonly username: string; + readonly name: string; +} + +// From codersdk/users.go +export interface UpdateUserQuietHoursScheduleRequest { + readonly schedule: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceAutomaticUpdatesRequest { + readonly automatic_updates: AutomaticUpdates; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceAutostartRequest { + readonly schedule?: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceDormancy { + readonly dormant: boolean; +} + +// From codersdk/workspaceproxy.go +export interface UpdateWorkspaceProxyResponse { + readonly proxy: WorkspaceProxy; + readonly proxy_token: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceRequest { + readonly name?: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceTTLRequest { + readonly ttl_ms?: number; +} + +// From codersdk/files.go +export interface UploadResponse { + readonly hash: string; +} + +// From codersdk/workspaceagentportshare.go +export interface UpsertWorkspaceAgentPortShareRequest { + readonly agent_name: string; + readonly port: number; + readonly share_level: WorkspaceAgentPortShareLevel; + readonly protocol: WorkspaceAgentPortShareProtocol; +} + +// From codersdk/users.go +export interface User extends ReducedUser { + readonly organization_ids: readonly string[]; + readonly roles: readonly SlimRole[]; +} + +// From codersdk/insights.go +export interface UserActivity { + readonly template_ids: readonly string[]; + readonly user_id: string; + readonly username: string; + readonly avatar_url: string; + readonly seconds: number; +} + +// From codersdk/insights.go +export interface UserActivityInsightsReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly users: readonly UserActivity[]; +} + +// From codersdk/insights.go +export interface UserActivityInsightsRequest { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; +} + +// From codersdk/insights.go +export interface UserActivityInsightsResponse { + readonly report: UserActivityInsightsReport; +} + +// From codersdk/insights.go +export interface UserLatency { + readonly template_ids: readonly string[]; + readonly user_id: string; + readonly username: string; + readonly avatar_url: string; + readonly latency_ms: ConnectionLatency; +} + +// From codersdk/insights.go +export interface UserLatencyInsightsReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly users: readonly UserLatency[]; +} + +// From codersdk/insights.go +export interface UserLatencyInsightsRequest { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; +} + +// From codersdk/insights.go +export interface UserLatencyInsightsResponse { + readonly report: UserLatencyInsightsReport; +} + +// From codersdk/users.go +export interface UserLoginType { + readonly login_type: LoginType; +} + +// From codersdk/users.go +export interface UserParameter { + readonly name: string; + readonly value: string; +} + +// From codersdk/deployment.go +export interface UserQuietHoursScheduleConfig { + readonly default_schedule: string; + readonly allow_user_custom: boolean; +} + +// From codersdk/users.go +export interface UserQuietHoursScheduleResponse { + readonly raw_schedule: string; + readonly user_set: boolean; + readonly user_can_set: boolean; + readonly time: string; + readonly timezone: string; + readonly next: string; +} + +// From codersdk/users.go +export interface UserRoles { + readonly roles: readonly string[]; + readonly organization_roles: Record; +} + +// From codersdk/users.go +export interface UsersRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/client.go +export interface ValidationError { + readonly field: string; + readonly detail: string; +} + +// From codersdk/organizations.go +export interface VariableValue { + readonly name: string; + readonly value: string; +} + +// From codersdk/workspaces.go +export interface Workspace { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly owner_id: string; + readonly owner_name: string; + readonly owner_avatar_url: string; + readonly organization_id: string; + readonly template_id: string; + readonly template_name: string; + readonly template_display_name: string; + readonly template_icon: string; + readonly template_allow_user_cancel_workspace_jobs: boolean; + readonly template_active_version_id: string; + readonly template_require_active_version: boolean; + readonly latest_build: WorkspaceBuild; + readonly outdated: boolean; + readonly name: string; + readonly autostart_schedule?: string; + readonly ttl_ms?: number; + readonly last_used_at: string; + readonly deleting_at?: string; + readonly dormant_at?: string; + readonly health: WorkspaceHealth; + readonly automatic_updates: AutomaticUpdates; + readonly allow_renames: boolean; + readonly favorite: boolean; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgent { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly first_connected_at?: string; + readonly last_connected_at?: string; + readonly disconnected_at?: string; + readonly started_at?: string; + readonly ready_at?: string; + readonly status: WorkspaceAgentStatus; + readonly lifecycle_state: WorkspaceAgentLifecycle; + readonly name: string; + readonly resource_id: string; + readonly instance_id?: string; + readonly architecture: string; + readonly environment_variables: Record; + readonly operating_system: string; + readonly logs_length: number; + readonly logs_overflowed: boolean; + readonly directory?: string; + readonly expanded_directory?: string; + readonly version: string; + readonly api_version: string; + readonly apps: readonly WorkspaceApp[]; + readonly latency?: Record; + readonly connection_timeout_seconds: number; + readonly troubleshooting_url: string; + readonly subsystems: readonly AgentSubsystem[]; + readonly health: WorkspaceAgentHealth; + readonly display_apps: readonly DisplayApp[]; + readonly log_sources: readonly WorkspaceAgentLogSource[]; + readonly scripts: readonly WorkspaceAgentScript[]; + readonly startup_script_behavior: WorkspaceAgentStartupScriptBehavior; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentHealth { + readonly healthy: boolean; + readonly reason?: string; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentListeningPort { + readonly process_name: string; + readonly network: string; + readonly port: number; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentListeningPortsResponse { + readonly ports: readonly WorkspaceAgentListeningPort[]; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentLog { + readonly id: number; + readonly created_at: string; + readonly output: string; + readonly level: LogLevel; + readonly source_id: string; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentLogSource { + readonly workspace_agent_id: string; + readonly id: string; + readonly created_at: string; + readonly display_name: string; + readonly icon: string; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadata { + readonly result: WorkspaceAgentMetadataResult; + readonly description: WorkspaceAgentMetadataDescription; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadataDescription { + readonly display_name: string; + readonly key: string; + readonly script: string; + readonly interval: number; + readonly timeout: number; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadataResult { + readonly collected_at: string; + readonly age: number; + readonly value: string; + readonly error: string; +} + +// From codersdk/workspaceagentportshare.go +export interface WorkspaceAgentPortShare { + readonly workspace_id: string; + readonly agent_name: string; + readonly port: number; + readonly share_level: WorkspaceAgentPortShareLevel; + readonly protocol: WorkspaceAgentPortShareProtocol; +} + +// From codersdk/workspaceagentportshare.go +export interface WorkspaceAgentPortShares { + readonly shares: readonly WorkspaceAgentPortShare[]; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentScript { + readonly log_source_id: string; + readonly log_path: string; + readonly script: string; + readonly cron: string; + readonly run_on_start: boolean; + readonly run_on_stop: boolean; + readonly start_blocks_login: boolean; + readonly timeout: number; +} + +// From codersdk/workspaceapps.go +export interface WorkspaceApp { + readonly id: string; + readonly url: string; + readonly external: boolean; + readonly slug: string; + readonly display_name: string; + readonly command?: string; + readonly icon?: string; + readonly subdomain: boolean; + readonly subdomain_name?: string; + readonly sharing_level: WorkspaceAppSharingLevel; + readonly healthcheck: Healthcheck; + readonly health: WorkspaceAppHealth; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceBuild { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly workspace_id: string; + readonly workspace_name: string; + readonly workspace_owner_id: string; + readonly workspace_owner_name: string; + readonly workspace_owner_avatar_url: string; + readonly template_version_id: string; + readonly template_version_name: string; + readonly build_number: number; + readonly transition: WorkspaceTransition; + readonly initiator_id: string; + readonly initiator_name: string; + readonly job: ProvisionerJob; + readonly reason: BuildReason; + readonly resources: readonly WorkspaceResource[]; + readonly deadline?: string; + readonly max_deadline?: string; + readonly status: WorkspaceStatus; + readonly daily_cost: number; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceBuildParameter { + readonly name: string; + readonly value: string; +} + +// From codersdk/workspaces.go +export interface WorkspaceBuildsRequest extends Pagination { + readonly since?: string; +} + +// From codersdk/deployment.go +export interface WorkspaceConnectionLatencyMS { + readonly P50: number; + readonly P95: number; +} + +// From codersdk/deployment.go +export interface WorkspaceDeploymentStats { + readonly pending: number; + readonly building: number; + readonly running: number; + readonly failed: number; + readonly stopped: number; + readonly connection_latency_ms: WorkspaceConnectionLatencyMS; + readonly rx_bytes: number; + readonly tx_bytes: number; +} + +// From codersdk/workspaces.go +export interface WorkspaceFilter { + readonly q?: string; +} + +// From codersdk/workspaces.go +export interface WorkspaceHealth { + readonly healthy: boolean; + readonly failing_agents: readonly string[]; +} + +// From codersdk/workspaces.go +export interface WorkspaceOptions { + readonly include_deleted?: boolean; +} + +// From codersdk/workspaceproxy.go +export interface WorkspaceProxy extends Region { + readonly derp_enabled: boolean; + readonly derp_only: boolean; + readonly status?: WorkspaceProxyStatus; + readonly created_at: string; + readonly updated_at: string; + readonly deleted: boolean; + readonly version: string; +} + +// From codersdk/deployment.go +export interface WorkspaceProxyBuildInfo { + readonly workspace_proxy: boolean; + readonly dashboard_url: string; +} + +// From codersdk/workspaceproxy.go +export interface WorkspaceProxyStatus { + readonly status: ProxyHealthStatus; + readonly report?: ProxyHealthReport; + readonly checked_at: string; +} + +// From codersdk/workspaces.go +export interface WorkspaceQuota { + readonly credits_consumed: number; + readonly budget: number; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceResource { + readonly id: string; + readonly created_at: string; + readonly job_id: string; + readonly workspace_transition: WorkspaceTransition; + readonly type: string; + readonly name: string; + readonly hide: boolean; + readonly icon: string; + readonly agents?: readonly WorkspaceAgent[]; + readonly metadata?: readonly WorkspaceResourceMetadata[]; + readonly daily_cost: number; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceResourceMetadata { + readonly key: string; + readonly value: string; + readonly sensitive: boolean; +} + +// From codersdk/workspaces.go +export interface WorkspacesRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/workspaces.go +export interface WorkspacesResponse { + readonly workspaces: readonly Workspace[]; + readonly count: number; +} + +// From codersdk/apikey.go +export type APIKeyScope = 'all' | 'application_connect'; +export const APIKeyScopes: APIKeyScope[] = ['all', 'application_connect']; + +// From codersdk/workspaceagents.go +export type AgentSubsystem = 'envbox' | 'envbuilder' | 'exectrace'; +export const AgentSubsystems: AgentSubsystem[] = [ + 'envbox', + 'envbuilder', + 'exectrace', +]; + +// From codersdk/audit.go +export type AuditAction = + | 'create' + | 'delete' + | 'login' + | 'logout' + | 'register' + | 'start' + | 'stop' + | 'write'; +export const AuditActions: AuditAction[] = [ + 'create', + 'delete', + 'login', + 'logout', + 'register', + 'start', + 'stop', + 'write', +]; + +// From codersdk/workspaces.go +export type AutomaticUpdates = 'always' | 'never'; +export const AutomaticUpdateses: AutomaticUpdates[] = ['always', 'never']; + +// From codersdk/workspacebuilds.go +export type BuildReason = 'autostart' | 'autostop' | 'initiator'; +export const BuildReasons: BuildReason[] = [ + 'autostart', + 'autostop', + 'initiator', +]; + +// From codersdk/workspaceagents.go +export type DisplayApp = + | 'port_forwarding_helper' + | 'ssh_helper' + | 'vscode' + | 'vscode_insiders' + | 'web_terminal'; +export const DisplayApps: DisplayApp[] = [ + 'port_forwarding_helper', + 'ssh_helper', + 'vscode', + 'vscode_insiders', + 'web_terminal', +]; + +// From codersdk/externalauth.go +export type EnhancedExternalAuthProvider = + | 'azure-devops' + | 'azure-devops-entra' + | 'bitbucket-cloud' + | 'bitbucket-server' + | 'gitea' + | 'github' + | 'gitlab' + | 'jfrog' + | 'slack'; +export const EnhancedExternalAuthProviders: EnhancedExternalAuthProvider[] = [ + 'azure-devops', + 'azure-devops-entra', + 'bitbucket-cloud', + 'bitbucket-server', + 'gitea', + 'github', + 'gitlab', + 'jfrog', + 'slack', +]; + +// From codersdk/deployment.go +export type Entitlement = 'entitled' | 'grace_period' | 'not_entitled'; +export const entitlements: Entitlement[] = [ + 'entitled', + 'grace_period', + 'not_entitled', +]; + +// From codersdk/deployment.go +export type Experiment = + | 'auto-fill-parameters' + | 'custom-roles' + | 'example' + | 'multi-organization'; +export const experiments: Experiment[] = [ + 'auto-fill-parameters', + 'custom-roles', + 'example', + 'multi-organization', +]; + +// From codersdk/deployment.go +export type FeatureName = + | 'access_control' + | 'advanced_template_scheduling' + | 'appearance' + | 'audit_log' + | 'browser_only' + | 'control_shared_ports' + | 'custom_roles' + | 'external_provisioner_daemons' + | 'external_token_encryption' + | 'high_availability' + | 'multiple_external_auth' + | 'scim' + | 'template_rbac' + | 'user_limit' + | 'user_role_management' + | 'workspace_batch_actions' + | 'workspace_proxy'; +export const FeatureNames: FeatureName[] = [ + 'access_control', + 'advanced_template_scheduling', + 'appearance', + 'audit_log', + 'browser_only', + 'control_shared_ports', + 'custom_roles', + 'external_provisioner_daemons', + 'external_token_encryption', + 'high_availability', + 'multiple_external_auth', + 'scim', + 'template_rbac', + 'user_limit', + 'user_role_management', + 'workspace_batch_actions', + 'workspace_proxy', +]; + +// From codersdk/groups.go +export type GroupSource = 'oidc' | 'user'; +export const GroupSources: GroupSource[] = ['oidc', 'user']; + +// From codersdk/insights.go +export type InsightsReportInterval = 'day' | 'week'; +export const InsightsReportIntervals: InsightsReportInterval[] = [ + 'day', + 'week', +]; + +// From codersdk/provisionerdaemons.go +export type JobErrorCode = 'REQUIRED_TEMPLATE_VARIABLES'; +export const JobErrorCodes: JobErrorCode[] = ['REQUIRED_TEMPLATE_VARIABLES']; + +// From codersdk/provisionerdaemons.go +export type LogLevel = 'debug' | 'error' | 'info' | 'trace' | 'warn'; +export const LogLevels: LogLevel[] = [ + 'debug', + 'error', + 'info', + 'trace', + 'warn', +]; + +// From codersdk/provisionerdaemons.go +export type LogSource = 'provisioner' | 'provisioner_daemon'; +export const LogSources: LogSource[] = ['provisioner', 'provisioner_daemon']; + +// From codersdk/apikey.go +export type LoginType = '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +export const LoginTypes: LoginType[] = [ + '', + 'github', + 'none', + 'oidc', + 'password', + 'token', +]; + +// From codersdk/oauth2.go +export type OAuth2ProviderGrantType = 'authorization_code' | 'refresh_token'; +export const OAuth2ProviderGrantTypes: OAuth2ProviderGrantType[] = [ + 'authorization_code', + 'refresh_token', +]; + +// From codersdk/oauth2.go +export type OAuth2ProviderResponseType = 'code'; +export const OAuth2ProviderResponseTypes: OAuth2ProviderResponseType[] = [ + 'code', +]; + +// From codersdk/deployment.go +export type PostgresAuth = 'awsiamrds' | 'password'; +export const PostgresAuths: PostgresAuth[] = ['awsiamrds', 'password']; + +// From codersdk/provisionerdaemons.go +export type ProvisionerJobStatus = + | 'canceled' + | 'canceling' + | 'failed' + | 'pending' + | 'running' + | 'succeeded' + | 'unknown'; +export const ProvisionerJobStatuses: ProvisionerJobStatus[] = [ + 'canceled', + 'canceling', + 'failed', + 'pending', + 'running', + 'succeeded', + 'unknown', +]; + +// From codersdk/workspaces.go +export type ProvisionerLogLevel = 'debug'; +export const ProvisionerLogLevels: ProvisionerLogLevel[] = ['debug']; + +// From codersdk/organizations.go +export type ProvisionerStorageMethod = 'file'; +export const ProvisionerStorageMethods: ProvisionerStorageMethod[] = ['file']; + +// From codersdk/organizations.go +export type ProvisionerType = 'echo' | 'terraform'; +export const ProvisionerTypes: ProvisionerType[] = ['echo', 'terraform']; + +// From codersdk/workspaceproxy.go +export type ProxyHealthStatus = + | 'ok' + | 'unhealthy' + | 'unreachable' + | 'unregistered'; +export const ProxyHealthStatuses: ProxyHealthStatus[] = [ + 'ok', + 'unhealthy', + 'unreachable', + 'unregistered', +]; + +// From codersdk/rbacresources_gen.go +export type RBACAction = + | 'application_connect' + | 'assign' + | 'create' + | 'delete' + | 'read' + | 'read_personal' + | 'ssh' + | 'start' + | 'stop' + | 'update' + | 'update_personal' + | 'use' + | 'view_insights'; +export const RBACActions: RBACAction[] = [ + 'application_connect', + 'assign', + 'create', + 'delete', + 'read', + 'read_personal', + 'ssh', + 'start', + 'stop', + 'update', + 'update_personal', + 'use', + 'view_insights', +]; + +// From codersdk/rbacresources_gen.go +export type RBACResource = + | '*' + | 'api_key' + | 'assign_org_role' + | 'assign_role' + | 'audit_log' + | 'debug_info' + | 'deployment_config' + | 'deployment_stats' + | 'file' + | 'group' + | 'license' + | 'oauth2_app' + | 'oauth2_app_code_token' + | 'oauth2_app_secret' + | 'organization' + | 'organization_member' + | 'provisioner_daemon' + | 'replicas' + | 'system' + | 'tailnet_coordinator' + | 'template' + | 'user' + | 'workspace' + | 'workspace_dormant' + | 'workspace_proxy'; +export const RBACResources: RBACResource[] = [ + '*', + 'api_key', + 'assign_org_role', + 'assign_role', + 'audit_log', + 'debug_info', + 'deployment_config', + 'deployment_stats', + 'file', + 'group', + 'license', + 'oauth2_app', + 'oauth2_app_code_token', + 'oauth2_app_secret', + 'organization', + 'organization_member', + 'provisioner_daemon', + 'replicas', + 'system', + 'tailnet_coordinator', + 'template', + 'user', + 'workspace', + 'workspace_dormant', + 'workspace_proxy', +]; + +// From codersdk/audit.go +export type ResourceType = + | 'api_key' + | 'convert_login' + | 'git_ssh_key' + | 'group' + | 'health_settings' + | 'license' + | 'oauth2_provider_app' + | 'oauth2_provider_app_secret' + | 'organization' + | 'template' + | 'template_version' + | 'user' + | 'workspace' + | 'workspace_build' + | 'workspace_proxy'; +export const ResourceTypes: ResourceType[] = [ + 'api_key', + 'convert_login', + 'git_ssh_key', + 'group', + 'health_settings', + 'license', + 'oauth2_provider_app', + 'oauth2_provider_app_secret', + 'organization', + 'template', + 'template_version', + 'user', + 'workspace', + 'workspace_build', + 'workspace_proxy', +]; + +// From codersdk/serversentevents.go +export type ServerSentEventType = 'data' | 'error' | 'ping'; +export const ServerSentEventTypes: ServerSentEventType[] = [ + 'data', + 'error', + 'ping', +]; + +// From codersdk/insights.go +export type TemplateAppsType = 'app' | 'builtin'; +export const TemplateAppsTypes: TemplateAppsType[] = ['app', 'builtin']; + +// From codersdk/insights.go +export type TemplateInsightsSection = 'interval_reports' | 'report'; +export const TemplateInsightsSections: TemplateInsightsSection[] = [ + 'interval_reports', + 'report', +]; + +// From codersdk/templates.go +export type TemplateRole = '' | 'admin' | 'use'; +export const TemplateRoles: TemplateRole[] = ['', 'admin', 'use']; + +// From codersdk/templateversions.go +export type TemplateVersionWarning = 'UNSUPPORTED_WORKSPACES'; +export const TemplateVersionWarnings: TemplateVersionWarning[] = [ + 'UNSUPPORTED_WORKSPACES', +]; + +// From codersdk/users.go +export type UserStatus = 'active' | 'dormant' | 'suspended'; +export const UserStatuses: UserStatus[] = ['active', 'dormant', 'suspended']; + +// From codersdk/templateversions.go +export type ValidationMonotonicOrder = 'decreasing' | 'increasing'; +export const ValidationMonotonicOrders: ValidationMonotonicOrder[] = [ + 'decreasing', + 'increasing', +]; + +// From codersdk/workspaceagents.go +export type WorkspaceAgentLifecycle = + | 'created' + | 'off' + | 'ready' + | 'shutdown_error' + | 'shutdown_timeout' + | 'shutting_down' + | 'start_error' + | 'start_timeout' + | 'starting'; +export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [ + 'created', + 'off', + 'ready', + 'shutdown_error', + 'shutdown_timeout', + 'shutting_down', + 'start_error', + 'start_timeout', + 'starting', +]; + +// From codersdk/workspaceagentportshare.go +export type WorkspaceAgentPortShareLevel = 'authenticated' | 'owner' | 'public'; +export const WorkspaceAgentPortShareLevels: WorkspaceAgentPortShareLevel[] = [ + 'authenticated', + 'owner', + 'public', +]; + +// From codersdk/workspaceagentportshare.go +export type WorkspaceAgentPortShareProtocol = 'http' | 'https'; +export const WorkspaceAgentPortShareProtocols: WorkspaceAgentPortShareProtocol[] = + ['http', 'https']; + +// From codersdk/workspaceagents.go +export type WorkspaceAgentStartupScriptBehavior = 'blocking' | 'non-blocking'; +export const WorkspaceAgentStartupScriptBehaviors: WorkspaceAgentStartupScriptBehavior[] = + ['blocking', 'non-blocking']; + +// From codersdk/workspaceagents.go +export type WorkspaceAgentStatus = + | 'connected' + | 'connecting' + | 'disconnected' + | 'timeout'; +export const WorkspaceAgentStatuses: WorkspaceAgentStatus[] = [ + 'connected', + 'connecting', + 'disconnected', + 'timeout', +]; + +// From codersdk/workspaceapps.go +export type WorkspaceAppHealth = + | 'disabled' + | 'healthy' + | 'initializing' + | 'unhealthy'; +export const WorkspaceAppHealths: WorkspaceAppHealth[] = [ + 'disabled', + 'healthy', + 'initializing', + 'unhealthy', +]; + +// From codersdk/workspaceapps.go +export type WorkspaceAppSharingLevel = 'authenticated' | 'owner' | 'public'; +export const WorkspaceAppSharingLevels: WorkspaceAppSharingLevel[] = [ + 'authenticated', + 'owner', + 'public', +]; + +// From codersdk/workspacebuilds.go +export type WorkspaceStatus = + | 'canceled' + | 'canceling' + | 'deleted' + | 'deleting' + | 'failed' + | 'pending' + | 'running' + | 'starting' + | 'stopped' + | 'stopping'; +export const WorkspaceStatuses: WorkspaceStatus[] = [ + 'canceled', + 'canceling', + 'deleted', + 'deleting', + 'failed', + 'pending', + 'running', + 'starting', + 'stopped', + 'stopping', +]; + +// From codersdk/workspacebuilds.go +export type WorkspaceTransition = 'delete' | 'start' | 'stop'; +export const WorkspaceTransitions: WorkspaceTransition[] = [ + 'delete', + 'start', + 'stop', +]; + +// From codersdk/workspaceproxy.go +export type RegionTypes = Region | WorkspaceProxy; + +// The code below is generated from codersdk/healthsdk. + +// From healthsdk/healthsdk.go +export interface AccessURLReport extends BaseReport { + readonly healthy: boolean; + readonly access_url: string; + readonly reachable: boolean; + readonly status_code: number; + readonly healthz_response: string; +} + +// From healthsdk/healthsdk.go +export interface BaseReport { + readonly error?: string; + readonly severity: HealthSeverity; + readonly warnings: readonly HealthMessage[]; + readonly dismissed: boolean; +} + +// From healthsdk/healthsdk.go +export interface DERPHealthReport extends BaseReport { + readonly healthy: boolean; + readonly regions: Record; + // Named type "tailscale.com/net/netcheck.Report" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly netcheck?: any; + readonly netcheck_err?: string; + readonly netcheck_logs: readonly string[]; +} + +// From healthsdk/healthsdk.go +export interface DERPNodeReport { + readonly healthy: boolean; + readonly severity: HealthSeverity; + readonly warnings: readonly HealthMessage[]; + readonly error?: string; + // Named type "tailscale.com/tailcfg.DERPNode" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly node?: any; + // Named type "tailscale.com/derp.ServerInfoMessage" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly node_info: any; + readonly can_exchange_messages: boolean; + readonly round_trip_ping: string; + readonly round_trip_ping_ms: number; + readonly uses_websocket: boolean; + readonly client_logs: readonly (readonly string[])[]; + readonly client_errs: readonly (readonly string[])[]; + readonly stun: STUNReport; +} + +// From healthsdk/healthsdk.go +export interface DERPRegionReport { + readonly healthy: boolean; + readonly severity: HealthSeverity; + readonly warnings: readonly HealthMessage[]; + readonly error?: string; + // Named type "tailscale.com/tailcfg.DERPRegion" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly region?: any; + readonly node_reports: readonly DERPNodeReport[]; +} + +// From healthsdk/healthsdk.go +export interface DatabaseReport extends BaseReport { + readonly healthy: boolean; + readonly reachable: boolean; + readonly latency: string; + readonly latency_ms: number; + readonly threshold_ms: number; +} + +// From healthsdk/healthsdk.go +export interface HealthSettings { + readonly dismissed_healthchecks: readonly HealthSection[]; +} + +// From healthsdk/healthsdk.go +export interface HealthcheckReport { + readonly time: string; + readonly healthy: boolean; + readonly severity: HealthSeverity; + readonly failing_sections: readonly HealthSection[]; + readonly derp: DERPHealthReport; + readonly access_url: AccessURLReport; + readonly websocket: WebsocketReport; + readonly database: DatabaseReport; + readonly workspace_proxy: WorkspaceProxyReport; + readonly provisioner_daemons: ProvisionerDaemonsReport; + readonly coder_version: string; +} + +// From healthsdk/healthsdk.go +export interface ProvisionerDaemonsReport extends BaseReport { + readonly items: readonly ProvisionerDaemonsReportItem[]; +} + +// From healthsdk/healthsdk.go +export interface ProvisionerDaemonsReportItem { + readonly provisioner_daemon: ProvisionerDaemon; + readonly warnings: readonly HealthMessage[]; +} + +// From healthsdk/healthsdk.go +export interface STUNReport { + readonly Enabled: boolean; + readonly CanSTUN: boolean; + readonly Error?: string; +} + +// From healthsdk/healthsdk.go +export interface UpdateHealthSettings { + readonly dismissed_healthchecks: readonly HealthSection[]; +} + +// From healthsdk/healthsdk.go +export interface WebsocketReport extends BaseReport { + readonly healthy: boolean; + readonly body: string; + readonly code: number; +} + +// From healthsdk/healthsdk.go +export interface WorkspaceProxyReport extends BaseReport { + readonly healthy: boolean; + readonly workspace_proxies: RegionsResponse; +} + +// From healthsdk/healthsdk.go +export type HealthSection = + | 'AccessURL' + | 'DERP' + | 'Database' + | 'ProvisionerDaemons' + | 'Websocket' + | 'WorkspaceProxy'; +export const HealthSections: HealthSection[] = [ + 'AccessURL', + 'DERP', + 'Database', + 'ProvisionerDaemons', + 'Websocket', + 'WorkspaceProxy', +]; + +// The code below is generated from coderd/healthcheck/health. + +// From health/model.go +export interface HealthMessage { + readonly code: HealthCode; + readonly message: string; +} + +// From health/model.go +export type HealthCode = + | 'EACS01' + | 'EACS02' + | 'EACS03' + | 'EACS04' + | 'EDB01' + | 'EDB02' + | 'EDERP01' + | 'EDERP02' + | 'EPD01' + | 'EPD02' + | 'EPD03' + | 'EUNKNOWN' + | 'EWP01' + | 'EWP02' + | 'EWP04' + | 'EWS01' + | 'EWS02' + | 'EWS03'; +export const HealthCodes: HealthCode[] = [ + 'EACS01', + 'EACS02', + 'EACS03', + 'EACS04', + 'EDB01', + 'EDB02', + 'EDERP01', + 'EDERP02', + 'EPD01', + 'EPD02', + 'EPD03', + 'EUNKNOWN', + 'EWP01', + 'EWP02', + 'EWP04', + 'EWS01', + 'EWS02', + 'EWS03', +]; + +// From health/model.go +export type HealthSeverity = 'error' | 'ok' | 'warning'; +export const HealthSeveritys: HealthSeverity[] = ['error', 'ok', 'warning']; + +// The code below is generated from github.com/coder/serpent. + +// From serpent/serpent.go +export type SerpentAnnotations = Record; + +// From serpent/serpent.go +export interface SerpentGroup { + readonly parent?: SerpentGroup; + readonly name?: string; + readonly yaml?: string; + readonly description?: string; +} + +// From serpent/option.go +export interface SerpentOption { + readonly name?: string; + readonly description?: string; + readonly required?: boolean; + readonly flag?: string; + readonly flag_shorthand?: string; + readonly env?: string; + readonly yaml?: string; + readonly default?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Golang interface, unable to resolve type. + readonly value?: any; + readonly annotations?: SerpentAnnotations; + readonly group?: SerpentGroup; + readonly use_instead?: readonly SerpentOption[]; + readonly hidden?: boolean; + readonly value_source?: SerpentValueSource; +} + +// From serpent/option.go +export type SerpentOptionSet = readonly SerpentOption[]; + +// From serpent/option.go +export type SerpentValueSource = '' | 'default' | 'env' | 'flag' | 'yaml'; +export const SerpentValueSources: SerpentValueSource[] = [ + '', + 'default', + 'env', + 'flag', + 'yaml', +]; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts new file mode 100644 index 00000000..f8451116 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -0,0 +1,36 @@ +export type * from './api/typesGenerated'; +export type { + DeleteWorkspaceOptions, + GetLicensesResponse, + InsightsParams, + InsightsTemplateParams, +} from './api/api'; +import { Api } from './api/api'; + +// Union of all API properties that won't ever be relevant to Backstage users. +// Not a huge deal that they still exist at runtime; mainly concerned about +// whether they pollute Intellisense when someone is using the SDK. Most of +// these properties don't deal with APIs and are mainly helpers in Core +type PropertyToHide = + | 'getJFrogXRayScan' + | 'getCsrfToken' + | 'setSessionToken' + | 'setHost' + | 'getAvailableExperiments' + | 'login' + | 'logout' + | 'convertToOAUTH' + | 'waitForBuild' + | 'addMember' + | 'removeMember' + | 'getWorkspaceParameters'; + +// Wanted to have a CoderSdk class (mainly re-exporting the Api class as itself +// with the extra properties omitted). But because classes are wonky and exist +// as both runtime values and types, it didn't seem possible, even with things +// like class declarations. Making a new function is good enough for now, though +export type CoderSdk = Omit; +export function makeCoderSdk(): CoderSdk { + const api = new Api(); + return api as CoderSdk; +} diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts new file mode 100644 index 00000000..b915a7fb --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts @@ -0,0 +1,4 @@ +export const delay = (ms: number): Promise => + new Promise(res => { + setTimeout(res, ms); + }); diff --git a/yarn.lock b/yarn.lock index e7553d7d..c287f84a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8919,6 +8919,11 @@ dependencies: "@types/node" "*" +"@types/ua-parser-js@^0.7.39": + version "0.7.39" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz#832c58e460c9435e4e34bb866e85e9146e12cdbb" + integrity sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg== + "@types/unist@^2", "@types/unist@^2.0.0": version "2.0.10" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" @@ -11859,6 +11864,11 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +dayjs@^1.11.11: + version "1.11.11" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.11.tgz#dfe0e9d54c5f8b68ccf8ca5f72ac603e7e5ed59e" + integrity sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg== + debounce@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" @@ -20248,14 +20258,6 @@ react-dom@^18.0.2: loose-envify "^1.1.0" scheduler "^0.23.0" -react-dom@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== - dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.2" - react-double-scrollbar@0.0.15: version "0.0.15" resolved "https://registry.yarnpkg.com/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz#e915ab8cb3b959877075f49436debfdb04288fe4" @@ -21188,13 +21190,6 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -scheduler@^0.23.2: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" - schema-utils@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" @@ -22953,6 +22948,11 @@ typescript@~5.2.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +ua-parser-js@^1.0.37: + version "1.0.37" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" + integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== + uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" From c245950fba57a1432b1dcc2cf8386af8d1e71dd2 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Mon, 3 Jun 2024 10:23:17 -0400 Subject: [PATCH 27/33] chore(Coder plugin): update all Backstage code to use preview SDK (#131) * chore: add vendored version of experimental Coder SDK * chore: update CoderClient class to use new SDK * chore: delete mock SDK * fix: improve data hiding for CoderSdk * docs: update typo * wip: commit progress on updating Coder client * wip: commit more progress on updating types * chore: remove valibot type definitions from global constants file * chore: rename mocks file * fix: update type mismatches * wip: commit more update progress * wip: commit progress on updating client/SDK integration * fix: get all tests passing for CoderClient * fix: update UrlSync updates * fix: get all tests passing * chore: update all mock data to use Coder core entity mocks * fix: add extra helpers to useCoderSdk * fix: add additional properties to hide from SDK * fix: shrink down the API of useCoderSdk * update method name for clarity * chore: removal vestigal endpoint properties --- .../src/api/CoderClient.test.ts | 60 +--- .../src/api/CoderClient.ts | 81 ++--- .../src/api/MockCoderSdk.ts | 48 --- .../src/api/UrlSync.test.ts | 8 +- .../backstage-plugin-coder/src/api/UrlSync.ts | 10 +- .../src/api/queryOptions.ts | 12 +- .../CoderErrorBoundary/CoderErrorBoundary.tsx | 2 +- .../CoderProvider/CoderAuthProvider.tsx | 24 +- .../CoderWorkspacesCard.test.tsx | 2 +- .../ReminderAccordion.test.tsx | 2 +- .../components/CoderWorkspacesCard/Root.tsx | 2 +- .../WorkspacesList.test.tsx | 4 +- .../CoderWorkspacesCard/WorkspacesList.tsx | 2 +- .../WorkspacesListItem.test.tsx | 10 +- .../WorkspacesListItem.tsx | 3 +- .../src/hooks/hookPolyfills.ts | 8 +- .../src/hooks/useCoderSdk.ts | 10 +- .../src/hooks/useCoderWorkspacesQuery.test.ts | 2 +- .../src/hooks/useCoderWorkspacesQuery.ts | 6 +- .../src/hooks/useUrlSync.test.tsx | 6 +- .../src/testHelpers/coderEntities.ts | 305 ++++++++++++++++++ .../src/testHelpers/mockBackstageData.ts | 18 +- ...CoderAppData.ts => mockCoderPluginData.ts} | 65 +++- .../src/testHelpers/server.ts | 19 +- .../src/typesConstants.ts | 85 ----- .../src/utils/workspaces.ts | 2 +- 26 files changed, 487 insertions(+), 309 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts create mode 100644 plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts rename plugins/backstage-plugin-coder/src/testHelpers/{mockCoderAppData.ts => mockCoderPluginData.ts} (61%) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 9addcd1a..2bfa6b24 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -1,8 +1,4 @@ -import { - CODER_AUTH_HEADER_KEY, - CoderClient, - disabledClientError, -} from './CoderClient'; +import { CODER_AUTH_HEADER_KEY, CoderClient } from './CoderClient'; import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; import { rest } from 'msw'; @@ -12,8 +8,8 @@ import { delay } from '../utils/time'; import { mockWorkspacesList, mockWorkspacesListForRepoSearch, -} from '../testHelpers/mockCoderAppData'; -import type { Workspace, WorkspacesResponse } from '../typesConstants'; +} from '../testHelpers/mockCoderPluginData'; +import type { Workspace, WorkspacesResponse } from './vendoredSdk'; import { getMockConfigApi, getMockDiscoveryApi, @@ -100,50 +96,6 @@ describe(`${CoderClient.name}`, () => { }); }); - describe('cleanupClient functionality', () => { - it('Will prevent any new SDK requests from going through', async () => { - const client = new CoderClient({ apis: getConstructorApis() }); - client.cleanupClient(); - - // Request should fail, even though token is valid - await expect(() => { - return client.syncToken(mockCoderAuthToken); - }).rejects.toThrow(disabledClientError); - - await expect(() => { - return client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - }).rejects.toThrow(disabledClientError); - }); - - it('Will abort any pending requests', async () => { - const client = new CoderClient({ - initialToken: mockCoderAuthToken, - apis: getConstructorApis(), - }); - - // Sanity check to ensure that request can still go through normally - const workspacesPromise1 = client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - - await expect(workspacesPromise1).resolves.toEqual({ - workspaces: mockWorkspacesList, - count: mockWorkspacesList.length, - }); - - const workspacesPromise2 = client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - client.cleanupClient(); - await expect(() => workspacesPromise2).rejects.toThrow(); - }); - }); - // Eventually the Coder SDK is going to get too big to test every single // function. Focus tests on the functionality specifically being patched in // for Backstage @@ -180,10 +132,10 @@ describe(`${CoderClient.name}`, () => { }); const { urlSync } = apis; - const apiEndpoint = await urlSync.getApiEndpoint(); + const assetsEndpoint = await urlSync.getAssetsEndpoint(); - const allWorkspacesAreRemapped = !workspaces.some(ws => - ws.template_icon.startsWith(apiEndpoint), + const allWorkspacesAreRemapped = workspaces.every(ws => + ws.template_icon.startsWith(assetsEndpoint), ); expect(allWorkspacesAreRemapped).toBe(true); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 7c09f72c..4c5333dd 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -1,19 +1,19 @@ -import globalAxios, { +import { AxiosError, - type AxiosInstance, type InternalAxiosRequestConfig as RequestConfig, } from 'axios'; import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; -import { - type Workspace, - CODER_API_REF_ID_PREFIX, - WorkspacesRequest, - WorkspacesResponse, - User, -} from '../typesConstants'; +import { CODER_API_REF_ID_PREFIX } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { CoderSdk } from './MockCoderSdk'; +import { + type CoderSdk, + type User, + type Workspace, + type WorkspacesRequest, + type WorkspacesResponse, + makeCoderSdk, +} from './vendoredSdk'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; @@ -39,11 +39,6 @@ type CoderClientApi = Readonly<{ * Return value indicates whether the token is valid. */ syncToken: (newToken: string) => Promise; - - /** - * Cleans up a client instance, removing its links to all external systems. - */ - cleanupClient: () => void; }>; const sharedCleanupAbortReason = new DOMException( @@ -59,19 +54,30 @@ export const disabledClientError = new Error( ); type ConstructorInputs = Readonly<{ + /** + * initialToken is strictly for testing, and is basically limited to making it + * easier to test API logic. + * + * If trying to test UI logic that depends on CoderClient, it's probably + * better to interact with CoderClient indirectly through the auth components, + * so that React state is aware of everything. + */ initialToken?: string; - requestTimeoutMs?: number; + requestTimeoutMs?: number; apis: Readonly<{ urlSync: UrlSync; identityApi: IdentityApi; }>; }>; +type RequestInterceptor = ( + config: RequestConfig, +) => RequestConfig | Promise; + export class CoderClient implements CoderClientApi { private readonly urlSync: UrlSync; private readonly identityApi: IdentityApi; - private readonly axios: AxiosInstance; private readonly requestTimeoutMs: number; private readonly cleanupController: AbortController; @@ -82,33 +88,28 @@ export class CoderClient implements CoderClientApi { constructor(inputs: ConstructorInputs) { const { - apis, initialToken, + apis: { urlSync, identityApi }, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, } = inputs; - const { urlSync, identityApi } = apis; this.urlSync = urlSync; this.identityApi = identityApi; - this.axios = globalAxios.create(); - this.loadedSessionToken = initialToken; this.requestTimeoutMs = requestTimeoutMs; - this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); - this.sdk = this.getBackstageCoderSdk(this.axios); + this.sdk = this.createBackstageCoderSdk(); this.addBaseRequestInterceptors(); } private addRequestInterceptor( - requestInterceptor: ( - config: RequestConfig, - ) => RequestConfig | Promise, + requestInterceptor: RequestInterceptor, errorInterceptor?: (error: unknown) => unknown, ): number { - const ejectionId = this.axios.interceptors.request.use( + const axios = this.sdk.getAxiosInstance(); + const ejectionId = axios.interceptors.request.use( requestInterceptor, errorInterceptor, ); @@ -120,7 +121,8 @@ export class CoderClient implements CoderClientApi { private removeRequestInterceptorById(ejectionId: number): boolean { // Even if we somehow pass in an ID that hasn't been associated with the // Axios instance, that's a noop. No harm in calling method no matter what - this.axios.interceptors.request.eject(ejectionId); + const axios = this.sdk.getAxiosInstance(); + axios.interceptors.request.eject(ejectionId); if (!this.trackedEjectionIds.has(ejectionId)) { return false; @@ -179,10 +181,8 @@ export class CoderClient implements CoderClientApi { this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } - private getBackstageCoderSdk( - axiosInstance: AxiosInstance, - ): BackstageCoderSdk { - const baseSdk = new CoderSdk(axiosInstance); + private createBackstageCoderSdk(): BackstageCoderSdk { + const baseSdk = makeCoderSdk(); const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { const workspacesRes = await baseSdk.getWorkspaces(request); @@ -335,23 +335,6 @@ export class CoderClient implements CoderClientApi { this.removeRequestInterceptorById(validationId); } }; - - cleanupClient = (): void => { - this.trackedEjectionIds.forEach(id => { - this.axios.interceptors.request.eject(id); - }); - - this.trackedEjectionIds.clear(); - this.cleanupController.abort(sharedCleanupAbortReason); - this.loadedSessionToken = undefined; - - // Not using this.addRequestInterceptor, because we don't want to track this - // interceptor at all. It should never be ejected once the client has been - // disabled - this.axios.interceptors.request.use(() => { - throw disabledClientError; - }); - }; } function appendParamToQuery( diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts deleted file mode 100644 index 3100242b..00000000 --- a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file This is a temporary (and significantly limited) implementation of the - * "Coder SDK" that will eventually be imported from Coder core - * - * @todo Replace this with a full, proper implementation, and then expose it to - * plugin users. - */ -import globalAxios, { type AxiosInstance } from 'axios'; -import { - type User, - type WorkspacesRequest, - type WorkspacesResponse, -} from '../typesConstants'; - -type CoderSdkApi = { - getAuthenticatedUser: () => Promise; - getWorkspaces: (request: WorkspacesRequest) => Promise; -}; - -export class CoderSdk implements CoderSdkApi { - private readonly axios: AxiosInstance; - - constructor(axiosInstance?: AxiosInstance) { - this.axios = axiosInstance ?? globalAxios.create(); - } - - getWorkspaces = async ( - request: WorkspacesRequest, - ): Promise => { - const urlParams = new URLSearchParams({ - q: request.q ?? '', - limit: String(request.limit || 0), - after_id: request.after_id ?? '', - offset: String(request.offset || 0), - }); - - const response = await this.axios.get( - `/workspaces?${urlParams.toString()}`, - ); - - return response.data; - }; - - getAuthenticatedUser = async (): Promise => { - const response = await this.axios.get('/users/me'); - return response.data; - }; -} diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 4932edea..62001e4e 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -4,8 +4,8 @@ import { getMockConfigApi, getMockDiscoveryApi, mockBackstageAssetsEndpoint, - mockBackstageApiEndpoint, mockBackstageUrlRoot, + mockBackstageApiEndpointWithoutSdkPath, } from '../testHelpers/mockBackstageData'; // Tests have to assume that DiscoveryApi and ConfigApi will always be in sync, @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageApiEndpoint, + apiRoute: mockBackstageApiEndpointWithoutSdkPath, assetsRoute: mockBackstageAssetsEndpoint, }); }); @@ -50,7 +50,7 @@ describe(`${UrlSync.name}`, () => { expect(newSnapshot).toEqual({ baseUrl: 'blah', - apiRoute: 'blah/coder/api/v2', + apiRoute: 'blah/coder', assetsRoute: 'blah/coder', }); }); @@ -76,7 +76,7 @@ describe(`${UrlSync.name}`, () => { expect(onChange).toHaveBeenCalledWith({ baseUrl: 'blah', - apiRoute: 'blah/coder/api/v2', + apiRoute: 'blah/coder', assetsRoute: 'blah/coder', } satisfies UrlSyncSnapshot); diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index ae05294b..8b3548d6 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -42,14 +42,10 @@ const PROXY_URL_KEY_FOR_DISCOVERY_API = 'proxy'; type UrlPrefixes = Readonly<{ proxyPrefix: string; - apiRoutePrefix: string; - assetsRoutePrefix: string; }>; export const defaultUrlPrefixes = { proxyPrefix: `/api/proxy`, - apiRoutePrefix: '/api/v2', - assetsRoutePrefix: '', // Deliberately left as empty string } as const satisfies UrlPrefixes; export type UrlSyncSnapshot = Readonly<{ @@ -104,12 +100,10 @@ export class UrlSync implements UrlSyncApi { } private prepareNewSnapshot(newProxyUrl: string): UrlSyncSnapshot { - const { assetsRoutePrefix, apiRoutePrefix } = this.urlPrefixes; - return { baseUrl: newProxyUrl.replace(proxyRouteReplacer, ''), - assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${assetsRoutePrefix}`, - apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${apiRoutePrefix}`, + assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}`, + apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}`, }; } diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index b10ecfe2..4e55861d 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,5 +1,5 @@ import type { UseQueryOptions } from '@tanstack/react-query'; -import type { Workspace, WorkspacesRequest } from '../typesConstants'; +import type { Workspace, WorkspacesRequest } from './vendoredSdk'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import type { BackstageCoderSdk } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; @@ -44,13 +44,13 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { type WorkspacesFetchInputs = Readonly<{ auth: CoderAuth; - coderSdk: BackstageCoderSdk; + sdk: BackstageCoderSdk; coderQuery: string; }>; export function workspaces({ auth, - coderSdk, + sdk, coderQuery, }: WorkspacesFetchInputs): UseQueryOptions { const enabled = auth.isAuthenticated; @@ -61,7 +61,7 @@ export function workspaces({ keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { - const res = await coderSdk.getWorkspaces({ + const res = await sdk.getWorkspaces({ q: coderQuery, limit: 0, }); @@ -79,7 +79,7 @@ type WorkspacesByRepoFetchInputs = Readonly< export function workspacesByRepo({ coderQuery, - coderSdk, + sdk, auth, workspacesConfig, }: WorkspacesByRepoFetchInputs): UseQueryOptions { @@ -95,7 +95,7 @@ export function workspacesByRepo({ refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; - const res = await coderSdk.getWorkspacesByRepo(request, workspacesConfig); + const res = await sdk.getWorkspacesByRepo(request, workspacesConfig); return res.workspaces; }, }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx index c1f2bc61..5843a180 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx @@ -39,7 +39,7 @@ class ErrorBoundaryCore extends Component< render() { const { children, fallbackUi } = this.props; - return this.state.hasError ? fallbackUi : children; + return <>{this.state.hasError ? fallbackUi : children}; } } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index c9b6fbb1..664bb311 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -165,19 +165,23 @@ function useAuthState(): CoderAuth { return unsubscribe; }, [queryClient]); + const registerNewToken = useCallback((newToken: string) => { + if (newToken !== '') { + setAuthToken(newToken); + } + }, []); + + const ejectToken = useCallback(() => { + setAuthToken(''); + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); + }, [queryClient]); + return { ...authState, isAuthenticated: validAuthStatuses.includes(authState.status), - registerNewToken: newToken => { - if (newToken !== '') { - setAuthToken(newToken); - } - }, - ejectToken: () => { - setAuthToken(''); - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - }, + registerNewToken, + ejectToken, }; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx index a8cbef6c..8acc04a1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx @@ -10,7 +10,7 @@ import { mockWorkspaceNoParameters, mockWorkspaceWithMatch2, mockWorkspacesList, -} from '../../testHelpers/mockCoderAppData'; +} from '../../testHelpers/mockCoderPluginData'; import { type CoderAuthStatus } from '../CoderProvider'; import { CoderWorkspacesCard } from './CoderWorkspacesCard'; import userEvent from '@testing-library/user-event'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx index 0ae1d918..5be7284b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { type WorkspacesCardContext, diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 0866d95a..452f0a9c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -15,7 +15,7 @@ import { useCoderWorkspacesConfig, type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx index 50bc1de1..bc7e0273 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx @@ -3,8 +3,8 @@ import { type WorkspacesListProps, WorkspacesList } from './WorkspacesList'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { CardContext, WorkspacesCardContext, WorkspacesQuery } from './Root'; import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; -import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; -import { Workspace } from '../../typesConstants'; +import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderPluginData'; +import type { Workspace } from '../../api/vendoredSdk'; import { screen } from '@testing-library/react'; type RenderInputs = Readonly<{ diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index 1e47b08a..9301d6a4 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -1,7 +1,7 @@ import React, { type HTMLAttributes, type ReactNode, Fragment } from 'react'; import { type Theme, makeStyles } from '@material-ui/core'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { useWorkspacesCardContext } from './Root'; import { WorkspacesListItem } from './WorkspacesListItem'; import { Placeholder } from './Placeholder'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx index 03ff2623..471d3356 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx @@ -1,9 +1,13 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; -import type { Workspace } from '../../typesConstants'; +import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderPluginData'; +import type { Workspace } from '../../api/vendoredSdk'; import { WorkspacesListItem } from './WorkspacesListItem'; +import { + MockWorkspaceAgent, + MockWorkspaceResource, +} from '../../testHelpers/coderEntities'; type RenderInput = Readonly<{ isOnline?: boolean; @@ -19,9 +23,11 @@ async function renderListItem(inputs?: RenderInput) { status: isOnline ? 'running' : 'stopped', resources: [ { + ...MockWorkspaceResource, id: '1', agents: [ { + ...MockWorkspaceAgent, id: '2', status: isOnline ? 'connected' : 'disconnected', }, diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index f7292e51..a5a588ae 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -11,7 +11,8 @@ import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; import { getWorkspaceAgentStatuses } from '../../utils/workspaces'; -import type { Workspace, WorkspaceStatus } from '../../typesConstants'; +import type { WorkspaceStatus } from '../../api/vendoredSdk'; +import type { Workspace } from '../../api/vendoredSdk'; import { WorkspacesListIcon } from './WorkspacesListIcon'; import { VisuallyHidden } from '../VisuallyHidden'; diff --git a/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts b/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts index 3b777c5e..ce15f948 100644 --- a/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts +++ b/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts @@ -25,5 +25,11 @@ function useIdPolyfill(): string { return readonlyId; } +const ReactWithNewerHooks = React as typeof React & { + useId?: () => string; +}; + export const useId = - typeof React.useId === 'undefined' ? useIdPolyfill : React.useId; + typeof ReactWithNewerHooks.useId === 'undefined' + ? useIdPolyfill + : ReactWithNewerHooks.useId; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts index 8fbec12c..7b7017a1 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -1,7 +1,13 @@ +/** + * @file This defines the general helper for accessing the Coder SDK from + * Backstage in a type-safe way. + * + * This hook is meant to be used both internally AND externally. + */ import { useApi } from '@backstage/core-plugin-api'; import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; export function useCoderSdk(): BackstageCoderSdk { - const coderClient = useApi(coderClientApiRef); - return coderClient.sdk; + const { sdk } = useApi(coderClientApiRef); + return sdk; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts index d29e64a5..49535619 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts @@ -6,7 +6,7 @@ import { mockCoderWorkspacesConfig } from '../testHelpers/mockBackstageData'; import { mockWorkspaceNoParameters, mockWorkspacesList, -} from '../testHelpers/mockCoderAppData'; +} from '../testHelpers/mockCoderPluginData'; beforeAll(() => { jest.useFakeTimers(); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 4e41ef86..63b4f2f7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -13,13 +13,13 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { + const sdk = useCoderSdk(); const auth = useInternalCoderAuth(); - const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ auth, coderSdk, coderQuery, workspacesConfig }) - : workspaces({ auth, coderSdk, coderQuery }); + ? workspacesByRepo({ auth, sdk, coderQuery, workspacesConfig }) + : workspaces({ auth, sdk, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index 164242f7..90cac33d 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -6,13 +6,13 @@ import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import { mockBackstageAssetsEndpoint, - mockBackstageApiEndpoint, mockBackstageUrlRoot, getMockConfigApi, + mockBackstageApiEndpointWithoutSdkPath, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageApiEndpoint; + let proxyEndpoint: string = mockBackstageApiEndpointWithoutSdkPath; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -53,7 +53,7 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageApiEndpoint, + apiRoute: mockBackstageApiEndpointWithoutSdkPath, }, }), ); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts b/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts new file mode 100644 index 00000000..b5cf5abf --- /dev/null +++ b/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts @@ -0,0 +1,305 @@ +/** + * @file This is a subset of the mock data from the Coder OSS repo. No values + * are modified; if any values should be patched for Backstage testing, those + * should be updated in the mockCoderPluginData.ts file. + * + * @see {@link https://github.com/coder/coder/blob/main/site/src/testHelpers/entities.ts} + */ +import type * as TypesGen from '../api/vendoredSdk'; + +const MockOrganization: TypesGen.Organization = { + id: 'fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0', + name: 'Test Organization', + created_at: '', + updated_at: '', + is_default: true, +}; + +const MockOwnerRole: TypesGen.Role = { + name: 'owner', + display_name: 'Owner', + site_permissions: [], + organization_permissions: {}, + user_permissions: [], + organization_id: '', +}; + +export const MockUser: TypesGen.User = { + id: 'test-user', + username: 'TestUser', + email: 'test@coder.com', + created_at: '', + status: 'active', + organization_ids: [MockOrganization.id], + roles: [MockOwnerRole], + avatar_url: 'https://avatars.githubusercontent.com/u/95932066?s=200&v=4', + last_seen_at: '', + login_type: 'password', + theme_preference: '', + name: '', +}; + +const MockProvisionerJob: TypesGen.ProvisionerJob = { + created_at: '', + id: 'test-provisioner-job', + status: 'succeeded', + file_id: MockOrganization.id, + completed_at: '2022-05-17T17:39:01.382927298Z', + tags: { + scope: 'organization', + owner: '', + wowzers: 'whatatag', + isCapable: 'false', + department: 'engineering', + dreaming: 'true', + }, + queue_position: 0, + queue_size: 0, +}; + +const MockProvisioner: TypesGen.ProvisionerDaemon = { + created_at: '2022-05-17T17:39:01.382927298Z', + id: 'test-provisioner', + name: 'Test Provisioner', + provisioners: ['echo'], + tags: { scope: 'organization' }, + version: 'v2.34.5', + api_version: '1.0', +}; + +const MockTemplateVersion: TypesGen.TemplateVersion = { + id: 'test-template-version', + created_at: '2022-05-17T17:39:01.382927298Z', + updated_at: '2022-05-17T17:39:01.382927298Z', + template_id: 'test-template', + job: MockProvisionerJob, + name: 'test-version', + message: 'first version', + readme: `--- +name:Template test +--- +## Instructions +You can add instructions here + +[Some link info](https://coder.com)`, + created_by: MockUser, + archived: false, +}; + +const MockWorkspaceAgentLogSource: TypesGen.WorkspaceAgentLogSource = { + created_at: '2023-05-04T11:30:41.402072Z', + id: 'dc790496-eaec-4f88-a53f-8ce1f61a1fff', + display_name: 'Startup Script', + icon: '', + workspace_agent_id: '', +}; + +const MockBuildInfo: TypesGen.BuildInfoResponse = { + agent_api_version: '1.0', + external_url: 'file:///mock-url', + version: 'v99.999.9999+c9cdf14', + dashboard_url: 'https:///mock-url', + workspace_proxy: false, + upgrade_message: 'My custom upgrade message', + deployment_id: '510d407f-e521-4180-b559-eab4a6d802b8', +}; + +const MockWorkspaceApp: TypesGen.WorkspaceApp = { + id: 'test-app', + slug: 'test-app', + display_name: 'Test App', + icon: '', + subdomain: false, + health: 'disabled', + external: false, + url: '', + sharing_level: 'owner', + healthcheck: { + url: '', + interval: 0, + threshold: 0, + }, +}; + +const MockWorkspaceAgentScript: TypesGen.WorkspaceAgentScript = { + log_source_id: MockWorkspaceAgentLogSource.id, + cron: '', + log_path: '', + run_on_start: true, + run_on_stop: false, + script: "echo 'hello world'", + start_blocks_login: false, + timeout: 0, +}; + +export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { + apps: [MockWorkspaceApp], + architecture: 'amd64', + created_at: '', + environment_variables: {}, + id: 'test-workspace-agent', + name: 'a-workspace-agent', + operating_system: 'linux', + resource_id: '', + status: 'connected', + updated_at: '', + version: MockBuildInfo.version, + api_version: '1.0', + latency: { + 'Coder Embedded DERP': { + latency_ms: 32.55, + preferred: true, + }, + }, + connection_timeout_seconds: 120, + troubleshooting_url: 'https://coder.com/troubleshoot', + lifecycle_state: 'starting', + logs_length: 0, + logs_overflowed: false, + log_sources: [MockWorkspaceAgentLogSource], + scripts: [MockWorkspaceAgentScript], + startup_script_behavior: 'non-blocking', + subsystems: ['envbox', 'exectrace'], + health: { + healthy: true, + }, + display_apps: [ + 'ssh_helper', + 'port_forwarding_helper', + 'vscode', + 'vscode_insiders', + 'web_terminal', + ], +}; + +export const MockWorkspaceResource: TypesGen.WorkspaceResource = { + id: 'test-workspace-resource', + name: 'a-workspace-resource', + agents: [MockWorkspaceAgent], + created_at: '', + job_id: '', + type: 'google_compute_disk', + workspace_transition: 'start', + hide: false, + icon: '', + metadata: [{ key: 'size', value: '32GB', sensitive: false }], + daily_cost: 10, +}; + +const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { + build_number: 1, + created_at: '2022-05-17T17:39:01.382927298Z', + id: '1', + initiator_id: MockUser.id, + initiator_name: MockUser.username, + job: MockProvisionerJob, + template_version_id: MockTemplateVersion.id, + template_version_name: MockTemplateVersion.name, + transition: 'start', + updated_at: '2022-05-17T17:39:01.382927298Z', + workspace_name: 'test-workspace', + workspace_owner_id: MockUser.id, + workspace_owner_name: MockUser.username, + workspace_owner_avatar_url: MockUser.avatar_url, + workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', + deadline: '2022-05-17T23:39:00.00Z', + reason: 'initiator', + resources: [MockWorkspaceResource], + status: 'running', + daily_cost: 20, +}; + +const MockTemplate: TypesGen.Template = { + id: 'test-template', + created_at: '2022-05-17T17:39:01.382927298Z', + updated_at: '2022-05-18T17:39:01.382927298Z', + organization_id: MockOrganization.id, + name: 'test-template', + display_name: 'Test Template', + provisioner: MockProvisioner.provisioners[0], + active_version_id: MockTemplateVersion.id, + active_user_count: 1, + build_time_stats: { + start: { + P50: 1000, + P95: 1500, + }, + stop: { + P50: 1000, + P95: 1500, + }, + delete: { + P50: 1000, + P95: 1500, + }, + }, + description: 'This is a test description.', + default_ttl_ms: 24 * 60 * 60 * 1000, + activity_bump_ms: 1 * 60 * 60 * 1000, + autostop_requirement: { + days_of_week: ['sunday'], + weeks: 1, + }, + autostart_requirement: { + days_of_week: [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ], + }, + created_by_id: 'test-creator-id', + created_by_name: 'test_creator', + icon: '/icon/code.svg', + allow_user_cancel_workspace_jobs: true, + failure_ttl_ms: 0, + time_til_dormant_ms: 0, + time_til_dormant_autodelete_ms: 0, + allow_user_autostart: true, + allow_user_autostop: true, + require_active_version: false, + deprecated: false, + deprecation_message: '', + max_port_share_level: 'public', +}; + +const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = + { + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: 'CRON_TZ=Canada/Eastern 30 9 * * 1-5', + }; + +export const MockWorkspace: TypesGen.Workspace = { + id: 'test-workspace', + name: 'Test-Workspace', + created_at: '', + updated_at: '', + template_id: MockTemplate.id, + template_name: MockTemplate.name, + template_icon: MockTemplate.icon, + template_display_name: MockTemplate.display_name, + template_allow_user_cancel_workspace_jobs: + MockTemplate.allow_user_cancel_workspace_jobs, + template_active_version_id: MockTemplate.active_version_id, + template_require_active_version: MockTemplate.require_active_version, + outdated: false, + owner_id: MockUser.id, + organization_id: MockOrganization.id, + owner_name: MockUser.username, + owner_avatar_url: 'https://avatars.githubusercontent.com/u/7122116?v=4', + autostart_schedule: MockWorkspaceAutostartEnabled.schedule, + ttl_ms: 2 * 60 * 60 * 1000, + latest_build: MockWorkspaceBuild, + last_used_at: '2022-05-16T15:29:10.302441433Z', + health: { + healthy: true, + failing_agents: [], + }, + automatic_updates: 'never', + allow_renames: true, + favorite: false, +}; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 34f11218..8c96f8d2 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -67,13 +67,25 @@ export const rawRepoUrl = `${cleanedRepoUrl}/tree/main/`; export const mockBackstageUrlRoot = 'http://localhost:7007'; /** - * The API endpoint to use with the mock server during testing. + * A version of the mock API endpoint that doesn't have the Coder API versioning + * prefix. Mainly used for tests that need to assert that the core API URL is + * formatted correctly, before the CoderSdk adds anything else to the end + * + * The string literal expression is complicated, but hover over it to see what + * the final result is. + */ +export const mockBackstageApiEndpointWithoutSdkPath = + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}` as const; + +/** + * The API endpoint to use with the mock server during testing. Adds additional + * path information that will normally be added via the Coder SDK. * * The string literal expression is complicated, but hover over it to see what * the final result is. */ export const mockBackstageApiEndpoint = - `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; + `${mockBackstageApiEndpointWithoutSdkPath}/api/v2` as const; /** * The assets endpoint to use during testing. @@ -82,7 +94,7 @@ export const mockBackstageApiEndpoint = * the final result is. */ export const mockBackstageAssetsEndpoint = - `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.assetsRoutePrefix}` as const; + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}` as const; export const mockBearerToken = 'This-is-an-opaque-value-by-design'; export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X'; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts similarity index 61% rename from plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts rename to plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts index 412e0e05..a3bfb10d 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts @@ -1,21 +1,45 @@ -import type { Workspace } from '../typesConstants'; -import { mockBackstageApiEndpoint } from './mockBackstageData'; +import type { User, Workspace } from '../api/vendoredSdk'; +import { + MockUser, + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceResource, +} from './coderEntities'; +import { + mockBackstageApiEndpoint, + mockBackstageAssetsEndpoint, +} from './mockBackstageData'; + +export const mockUserWithProxyUrls: User = { + ...MockUser, + avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, +}; /** * The main mock for a workspace whose repo URL matches cleanedRepoUrl */ export const mockWorkspaceWithMatch: Workspace = { + ...MockWorkspace, id: 'workspace-with-match', name: 'Test-Workspace', template_icon: `${mockBackstageApiEndpoint}/emojis/dog.svg`, owner_name: 'lil brudder', + latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-with-match-build', status: 'running', resources: [ { + ...MockWorkspaceResource, id: 'workspace-with-match-resource', - agents: [{ id: 'test-workspace-agent', status: 'connected' }], + agents: [ + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent', + status: 'connected', + }, + ], }, ], }, @@ -28,17 +52,27 @@ export const mockWorkspaceWithMatch: Workspace = { * return multiple values back */ export const mockWorkspaceWithMatch2: Workspace = { + ...MockWorkspace, id: 'workspace-with-match-2', name: 'Another-Test', template_icon: `${mockBackstageApiEndpoint}/emojis/z.svg`, owner_name: 'Coach Z', + latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-with-match-2-build', status: 'running', resources: [ { + ...MockWorkspaceResource, id: 'workspace-with-match-2-resource', - agents: [{ id: 'test-workspace-agent', status: 'connected' }], + agents: [ + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent', + status: 'connected', + }, + ], }, ], }, @@ -49,19 +83,31 @@ export const mockWorkspaceWithMatch2: Workspace = { * cleanedRepoUrl */ export const mockWorkspaceNoMatch: Workspace = { + ...MockWorkspace, id: 'workspace-no-match', name: 'No-match', template_icon: `${mockBackstageApiEndpoint}/emojis/star.svg`, owner_name: 'homestar runner', + latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-no-match-build', status: 'stopped', resources: [ { + ...MockWorkspaceResource, id: 'workspace-no-match-resource', agents: [ - { id: 'test-workspace-agent-a', status: 'disconnected' }, - { id: 'test-workspace-agent-b', status: 'timeout' }, + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-a', + status: 'disconnected', + }, + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-b', + status: 'timeout', + }, ], }, ], @@ -72,17 +118,22 @@ export const mockWorkspaceNoMatch: Workspace = { * A workspace with no build parameters whatsoever */ export const mockWorkspaceNoParameters: Workspace = { + ...MockWorkspace, id: 'workspace-no-parameters', name: 'No-parameters', template_icon: `${mockBackstageApiEndpoint}/emojis/cheese.png`, owner_name: 'The Cheat', latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-no-parameters-build', status: 'running', resources: [ { + ...MockWorkspaceResource, id: 'workspace-no-parameters-resource', - agents: [{ id: 'test-workspace-c', status: 'timeout' }], + agents: [ + { ...MockWorkspaceAgent, id: 'test-workspace-c', status: 'timeout' }, + ], }, ], }, diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 69fe816a..bacd3f43 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -11,19 +11,18 @@ import { setupServer } from 'msw/node'; /* eslint-enable @backstage/no-undeclared-imports */ import { + mockUserWithProxyUrls, mockWorkspacesList, mockWorkspacesListForRepoSearch, -} from './mockCoderAppData'; +} from './mockCoderPluginData'; import { - mockBackstageAssetsEndpoint, mockBearerToken, mockCoderAuthToken, mockCoderWorkspacesConfig, mockBackstageApiEndpoint as root, } from './mockBackstageData'; -import type { WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; -import { User } from '../typesConstants'; +import type { User, WorkspacesResponse } from '../api/vendoredSdk'; type RestResolver = ResponseResolver< RestRequest, @@ -83,7 +82,6 @@ export function wrappedGet( export const mockServerEndpoints = { workspaces: `${root}/workspaces`, authenticatedUser: `${root}/users/me`, - workspaceBuildParameters: `${root}/workspacebuilds/:workspaceBuildId/parameters`, } as const satisfies Record; const mainTestHandlers: readonly RestHandler[] = [ @@ -93,7 +91,7 @@ const mainTestHandlers: readonly RestHandler[] = [ `param:"\\w+?=${repoUrl.replace('/', '\\/')}"`, ); - const queryText = String(req.url.searchParams.get('q')); + const queryText = String(req.url.searchParams.get('q') ?? ''); const requestContainsRepoInfo = paramMatcherRe.test(queryText); const baseWorkspaces = requestContainsRepoInfo @@ -129,14 +127,7 @@ const mainTestHandlers: readonly RestHandler[] = [ // This is the dummy request used to verify a user's auth status wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - id: '1', - avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, - username: 'blueberry', - }), - ); + return res(ctx.status(200), ctx.json(mockUserWithProxyUrls)); }), ]; diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index 76551f89..986696bd 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -1,14 +1,3 @@ -import { - type Output, - array, - number, - object, - string, - union, - literal, - optional, -} from 'valibot'; - export type ReadonlyJsonValue = | string | number @@ -30,80 +19,6 @@ export const CODER_API_REF_ID_PREFIX = 'backstage-plugin-coder'; export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; -export const workspaceAgentStatusSchema = union([ - literal('connected'), - literal('connecting'), - literal('disconnected'), - literal('timeout'), -]); - -export const workspaceAgentSchema = object({ - id: string(), - status: workspaceAgentStatusSchema, -}); - -export const workspaceResourceSchema = object({ - id: string(), - agents: optional(array(workspaceAgentSchema)), -}); - -export const workspaceStatusSchema = union([ - literal('canceled'), - literal('canceling'), - literal('deleted'), - literal('deleting'), - literal('failed'), - literal('pending'), - literal('running'), - literal('starting'), - literal('stopped'), - literal('stopping'), -]); - -export const workspaceBuildSchema = object({ - id: string(), - resources: array(workspaceResourceSchema), - status: workspaceStatusSchema, -}); - -export const workspaceSchema = object({ - id: string(), - name: string(), - template_icon: string(), - owner_name: string(), - latest_build: workspaceBuildSchema, -}); - -export const workspacesResponseSchema = object({ - count: number(), - workspaces: array(workspaceSchema), -}); - -export type WorkspaceAgentStatus = Output; -export type WorkspaceAgent = Output; -export type WorkspaceResource = Output; -export type WorkspaceStatus = Output; -export type WorkspaceBuild = Output; -export type Workspace = Output; -export type WorkspacesResponse = Output; - -export type WorkspacesRequest = Readonly<{ - after_id?: string; - limit?: number; - offset?: number; - q?: string; -}>; - -// This is actually the MinimalUser type from Coder core (User extends from -// ReducedUser, which extends from MinimalUser). Don't need all the properties -// until we roll out full SDK support, so going with the least privileged -// type definition for now -export type User = Readonly<{ - id: string; - username: string; - avatar_url: string; -}>; - /** * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to * retrying a failed API request 3 times before exposing an error to the UI diff --git a/plugins/backstage-plugin-coder/src/utils/workspaces.ts b/plugins/backstage-plugin-coder/src/utils/workspaces.ts index c36b6d4b..f9317a97 100644 --- a/plugins/backstage-plugin-coder/src/utils/workspaces.ts +++ b/plugins/backstage-plugin-coder/src/utils/workspaces.ts @@ -1,4 +1,4 @@ -import type { Workspace, WorkspaceAgentStatus } from '../typesConstants'; +import { Workspace, WorkspaceAgentStatus } from '../api/vendoredSdk'; export function getWorkspaceAgentStatuses( workspace: Workspace, From 251214e7b05d77c2971866f3da118d833126f762 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 4 Jun 2024 10:55:21 -0400 Subject: [PATCH 28/33] feat(Coder plugin): expose Coder SDK to Backstage end-users (#132) * chore: add vendored version of experimental Coder SDK * chore: update CoderClient class to use new SDK * chore: delete mock SDK * fix: improve data hiding for CoderSdk * docs: update typo * wip: commit progress on updating Coder client * wip: commit more progress on updating types * chore: remove valibot type definitions from global constants file * chore: rename mocks file * fix: update type mismatches * wip: commit more update progress * wip: commit progress on updating client/SDK integration * fix: get all tests passing for CoderClient * fix: update UrlSync updates * fix: get all tests passing * chore: update all mock data to use Coder core entity mocks * fix: add extra helpers to useCoderSdk * fix: add additional properties to hide from SDK * fix: shrink down the API of useCoderSdk * update method name for clarity * chore: removal vestigal endpoint properties * fix: update reversion --- .../src/api/queryOptions.ts | 5 +- .../src/api/vendoredSdk/api/api.ts | 2 +- .../CoderProvider/CoderAuthProvider.tsx | 84 +++++- .../CoderProvider/CoderProvider.test.tsx | 1 + .../CoderProvider/CoderProvider.tsx | 5 +- .../components/CoderWorkspacesCard/Root.tsx | 2 +- .../useCoderWorkspacesQuery.test.ts | 7 +- .../useCoderWorkspacesQuery.ts | 8 +- .../src/hooks/reactQueryWrappers.test.tsx | 248 ++++++++++++++++++ .../src/hooks/reactQueryWrappers.ts | 157 +++++++++++ plugins/backstage-plugin-coder/src/plugin.ts | 6 + .../src/testHelpers/setup.tsx | 23 +- 12 files changed, 522 insertions(+), 26 deletions(-) rename plugins/backstage-plugin-coder/src/{hooks => components/CoderWorkspacesCard}/useCoderWorkspacesQuery.test.ts (91%) rename plugins/backstage-plugin-coder/src/{hooks => components/CoderWorkspacesCard}/useCoderWorkspacesQuery.ts (66%) create mode 100644 plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx create mode 100644 plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 4e55861d..6bfbd800 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -4,7 +4,10 @@ import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import type { BackstageCoderSdk } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; -export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; +// Making the type more broad to hide some implementation details from the end +// user; the prefix should be treated as an opaque string we can change whenever +// we want +export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin' as string; // Defined here and not in CoderAuthProvider.ts to avoid circular dependency // issues diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts index e0eafd1d..bf293267 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts @@ -312,7 +312,7 @@ type RestartWorkspaceParameters = Readonly<{ export type DeleteWorkspaceOptions = Pick< TypesGen.CreateWorkspaceBuildRequest, - 'log_level' & 'orphan' + 'log_level' | 'orphan' >; type Claims = { diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 664bb311..33b5bc0a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -1,4 +1,5 @@ import React, { + type FC, type PropsWithChildren, createContext, useCallback, @@ -136,10 +137,16 @@ function useAuthState(): CoderAuth { return () => window.clearTimeout(distrustTimeoutId); }, [authState.status]); + const isAuthenticated = validAuthStatuses.includes(authState.status); + // Sets up subscription to spy on potentially-expired tokens. Can't do this // outside React because we let the user connect their own queryClient const queryClient = useQueryClient(); useEffect(() => { + if (!isAuthenticated) { + return undefined; + } + // Pseudo-mutex; makes sure that if we get a bunch of errors, only one // revalidation will be processed at a time let isRevalidatingToken = false; @@ -163,7 +170,7 @@ function useAuthState(): CoderAuth { const queryCache = queryClient.getQueryCache(); const unsubscribe = queryCache.subscribe(revalidateTokenOnError); return unsubscribe; - }, [queryClient]); + }, [queryClient, isAuthenticated]); const registerNewToken = useCallback((newToken: string) => { if (newToken !== '') { @@ -179,7 +186,7 @@ function useAuthState(): CoderAuth { return { ...authState, - isAuthenticated: validAuthStatuses.includes(authState.status), + isAuthenticated, registerNewToken, ejectToken, }; @@ -607,24 +614,75 @@ export const dummyTrackComponent: TrackComponent = () => { }; }; +export type FallbackAuthInputBehavior = 'restrained' | 'assertive' | 'hidden'; +type AuthFallbackProvider = FC< + Readonly< + PropsWithChildren<{ + isAuthenticated: boolean; + }> + > +>; + +// Matches each behavior for the fallback auth UI to a specific provider. This +// is screwy code, but by doing this, we ensure that if the user chooses not to +// have a dynamic auth fallback UI, their app will have far less tracking logic, +// meaning less performance overhead and fewer re-renders from something the +// user isn't even using +const fallbackProviders = { + hidden: ({ children }) => ( + + {children} + + ), + + assertive: ({ children, isAuthenticated }) => ( + // Don't need the live version of the tracker function if we're always + // going to be showing the fallback auth input no matter what + + {children} + {!isAuthenticated && } + + ), + + // Have to give function a name to satisfy ES Lint (rules of hooks) + restrained: function Restrained({ children, isAuthenticated }) { + const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); + const needFallbackUi = !isAuthenticated && hasNoAuthInputs; + + return ( + <> + + {children} + + + {needFallbackUi && ( + + + + )} + + ); + }, +} as const satisfies Record; + +export type CoderAuthProviderProps = Readonly< + PropsWithChildren<{ + fallbackAuthUiMode?: FallbackAuthInputBehavior; + }> +>; + export function CoderAuthProvider({ children, -}: Readonly>) { + fallbackAuthUiMode = 'restrained', +}: CoderAuthProviderProps) { const authState = useAuthState(); - const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); - const needFallbackUi = !authState.isAuthenticated && hasNoAuthInputs; + const AuthFallbackProvider = fallbackProviders[fallbackAuthUiMode]; return ( - + {children} - - - {needFallbackUi && ( - - - - )} + ); } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 73acc13c..382917d8 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -86,6 +86,7 @@ describe(`${CoderProvider.name}`, () => { {children} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index 1b825404..fd562851 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -46,12 +46,15 @@ export const CoderProvider = ({ children, appConfig, queryClient = defaultClient, + fallbackAuthUiMode = 'restrained', }: CoderProviderProps) => { return ( - {children} + + {children} + diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 452f0a9c..5814d55b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -16,7 +16,7 @@ import { type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; import type { Workspace } from '../../api/vendoredSdk'; -import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; +import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; export type WorkspacesQuery = UseQueryResult; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.test.ts similarity index 91% rename from plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts rename to plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.test.ts index 49535619..9f22cf94 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.test.ts @@ -1,12 +1,11 @@ import { waitFor } from '@testing-library/react'; import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; - -import { renderHookAsCoderEntity } from '../testHelpers/setup'; -import { mockCoderWorkspacesConfig } from '../testHelpers/mockBackstageData'; +import { renderHookAsCoderEntity } from '../../testHelpers/setup'; +import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { mockWorkspaceNoParameters, mockWorkspacesList, -} from '../testHelpers/mockCoderPluginData'; +} from '../../testHelpers/mockCoderPluginData'; beforeAll(() => { jest.useFakeTimers(); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts similarity index 66% rename from plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts rename to plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts index 63b4f2f7..5f82e6b7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { workspaces, workspacesByRepo } from '../api/queryOptions'; -import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -import { useCoderSdk } from './useCoderSdk'; -import { useInternalCoderAuth } from '../components/CoderProvider'; +import { workspaces, workspacesByRepo } from '../../api/queryOptions'; +import type { CoderWorkspacesConfig } from '../../hooks/useCoderWorkspacesConfig'; +import { useCoderSdk } from '../../hooks/useCoderSdk'; +import { useInternalCoderAuth } from '../../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx new file mode 100644 index 00000000..83309a08 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -0,0 +1,248 @@ +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { + QueryClient, + QueryKey, + UseQueryResult, +} from '@tanstack/react-query'; +import { + type UseCoderQueryOptions, + useCoderQuery, + CoderQueryFunction, +} from './reactQueryWrappers'; +import { + type CoderAuth, + CoderProvider, + useEndUserCoderAuth, +} from '../components/CoderProvider'; +import { + getMockApiList, + mockAppConfig, + mockCoderAuthToken, +} from '../testHelpers/mockBackstageData'; +import { + createInvertedPromise, + getMockQueryClient, +} from '../testHelpers/setup'; +import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; +import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; +import { mockWorkspacesList } from '../testHelpers/mockCoderPluginData'; + +type RenderUseQueryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Readonly<{ + authenticateOnMount?: boolean; + queryClient?: QueryClient; + queryOptions: UseCoderQueryOptions; +}>; + +async function renderCoderQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(options: RenderUseQueryOptions) { + const { + queryOptions, + authenticateOnMount = true, + queryClient = getMockQueryClient(), + } = options; + + let latestRegisterNewToken!: CoderAuth['registerNewToken']; + let latestEjectToken!: CoderAuth['ejectToken']; + const AuthEscapeHatch = () => { + const auth = useEndUserCoderAuth(); + latestRegisterNewToken = auth.registerNewToken; + latestEjectToken = auth.ejectToken; + + return null; + }; + + type Result = UseQueryResult; + const renderOutput = renderHook( + newOptions => useCoderQuery(newOptions), + { + initialProps: queryOptions, + wrapper: ({ children }) => { + const mainMarkup = ( + + + {children} + + + + ); + + return wrapInTestApp(mainMarkup) as unknown as typeof mainMarkup; + }, + }, + ); + + await waitFor(() => expect(renderOutput.result.current).not.toBeNull()); + + const registerMockToken = () => { + return act(() => latestRegisterNewToken(mockCoderAuthToken)); + }; + + const ejectToken = () => { + return act(() => latestEjectToken()); + }; + + if (authenticateOnMount) { + registerMockToken(); + } + + return { ...renderOutput, registerMockToken, ejectToken }; +} + +describe(`${useCoderQuery.name}`, () => { + /** + * Really wanted to make mock components for each test case, to simulate some + * of the steps of using the hook as an actual end-user, but the setup steps + * got to be a bit much, just because of all the dependencies to juggle. + * + * @todo Add a new describe block with custom components to mirror some + * example user flows + */ + describe('Hook functionality', () => { + it('Disables requests while user is not authenticated', async () => { + const { result, registerMockToken, ejectToken } = await renderCoderQuery({ + authenticateOnMount: false, + queryOptions: { + queryKey: ['workspaces'], + queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), + select: response => response.workspaces, + }, + }); + + expect(result.current.isLoading).toBe(true); + registerMockToken(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.data?.length).toBeGreaterThan(0); + }); + + ejectToken(); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + }); + + it("Automatically prefixes queryKey with the global Coder query key prefix if it isn't already there", async () => { + // Have to escape out the key because useQuery doesn't expose any way to + // access the key after it's been processed into a query result object + let processedQueryKey: QueryKey | undefined = undefined; + + const queryFnWithEscape: CoderQueryFunction = ({ queryKey }) => { + processedQueryKey = queryKey; + return Promise.resolve(mockWorkspacesList); + }; + + // Verify that key is updated if the prefix isn't already there + const { unmount } = await renderCoderQuery({ + queryOptions: { + queryKey: ['blah'], + queryFn: queryFnWithEscape, + }, + }); + + await waitFor(() => { + expect(processedQueryKey).toEqual([ + CODER_QUERY_KEY_PREFIX, + 'blah', + ]); + }); + + // Unmounting shouldn't really be necessary, but it helps guarantee that + // there's never any risks of states messing with each other + unmount(); + + // Verify that the key is unchanged if the prefix is already present + await renderCoderQuery({ + queryOptions: { + queryKey: [CODER_QUERY_KEY_PREFIX, 'nah'], + queryFn: queryFnWithEscape, + }, + }); + + await waitFor(() => { + expect(processedQueryKey).toEqual([ + CODER_QUERY_KEY_PREFIX, + 'nah', + ]); + }); + }); + + it('Disables everything when the user unlinks their access token', async () => { + const { result, ejectToken } = await renderCoderQuery({ + queryOptions: { + queryKey: ['workspaces'], + queryFn: () => Promise.resolve(mockWorkspacesList), + }, + }); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining>({ + isSuccess: true, + isPaused: false, + data: mockWorkspacesList, + }), + ); + }); + + ejectToken(); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining>({ + isLoading: true, + isPaused: false, + data: undefined, + }), + ); + }); + }); + + /** + * In case the title isn't clear (had to rewrite it a bunch), the flow is: + * + * 1. User gets authenticated + * 2. User makes a request that will fail + * 3. Before the request comes back, the user revokes their authentication + * 4. The failed request comes back, which would normally add error state, + * and kick off a bunch of retry logic for React Query + * 5. But the hook should tell the Query Client NOT retry the request + * because the user is no longer authenticated + */ + it('Will not retry a request if it gets sent out while the user is authenticated, but then fails after the user revokes authentication', async () => { + const { promise, reject } = createInvertedPromise(); + const queryFn = jest.fn(() => promise); + + const { ejectToken } = await renderCoderQuery({ + queryOptions: { + queryFn, + queryKey: ['blah'], + + // From the end user's perspective, the query should always retry, but + // the hook should override that when the user isn't authenticated + retry: true, + }, + }); + + await waitFor(() => expect(queryFn).toHaveBeenCalled()); + ejectToken(); + + queryFn.mockRestore(); + act(() => reject(new Error("Don't feel like giving you data today"))); + expect(queryFn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts new file mode 100644 index 00000000..6dff0240 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -0,0 +1,157 @@ +/** + * @file Defines a couple of wrappers over React Query/Tanstack Query that make + * it easier to use the Coder SDK within UI logic. + * + * These hooks are designed 100% for end-users, and should not be used + * internally. Use useEndUserCoderAuth when working with auth logic within these + * hooks. + * + * --- + * @todo 2024-05-28 - This isn't fully complete until we have an equivalent + * wrapper for useMutation, and have an idea of how useCoderQuery and + * useCoderMutation can be used together. + * + * Making the useMutation wrapper shouldn't be hard, but you want some good + * integration tests to verify that the two hooks can satisfy common user flows. + */ +import { + type QueryFunctionContext, + type QueryKey, + type UseQueryOptions, + type UseQueryResult, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; +import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; +import { useCoderSdk } from './useCoderSdk'; +import type { BackstageCoderSdk } from '../api/CoderClient'; + +export type CoderQueryFunctionContext = + QueryFunctionContext & { + sdk: BackstageCoderSdk; + }; + +export type CoderQueryFunction< + T = unknown, + TQueryKey extends QueryKey = QueryKey, +> = (context: CoderQueryFunctionContext) => Promise; + +export type UseCoderQueryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + UseQueryOptions, + // queryFn omitted so that a custom version can be patched in; all other + // properties omitted because they are officially deprecated in React Query v4 + // and outright removed in v5. Want better future-proofing + 'queryFn' | 'isDataEqual' | 'onError' | 'onSuccess' | 'onSettled' +> & { + queryFn: CoderQueryFunction; +}; + +export function useCoderQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + queryOptions: UseCoderQueryOptions, +): UseQueryResult { + const queryClient = useQueryClient(); + const { isAuthenticated } = useEndUserCoderAuth(); + const sdk = useCoderSdk(); + + let patchedQueryKey = queryOptions.queryKey; + if ( + patchedQueryKey === undefined || + patchedQueryKey[0] !== CODER_QUERY_KEY_PREFIX + ) { + const baseKey = + queryOptions.queryKey ?? queryClient.defaultQueryOptions().queryKey; + + if (baseKey === undefined) { + throw new Error('No queryKey value provided to useCoderQuery'); + } + + patchedQueryKey = [ + CODER_QUERY_KEY_PREFIX, + ...baseKey, + ] as QueryKey as TQueryKey; + } + + type Options = UseQueryOptions; + const patchedOptions: Options = { + ...queryOptions, + queryKey: patchedQueryKey, + enabled: isAuthenticated && (queryOptions.enabled ?? true), + keepPreviousData: + isAuthenticated && (queryOptions.keepPreviousData ?? false), + refetchIntervalInBackground: + isAuthenticated && (queryOptions.refetchIntervalInBackground ?? false), + + queryFn: async context => { + if (!isAuthenticated) { + throw new Error('Cannot complete request - user is not authenticated'); + } + + return queryOptions.queryFn({ ...context, sdk }); + }, + + refetchInterval: (data, query) => { + if (!isAuthenticated) { + return false; + } + + const externalRefetchInterval = queryOptions.refetchInterval; + if (typeof externalRefetchInterval !== 'function') { + return externalRefetchInterval ?? false; + } + + return externalRefetchInterval(data, query); + }, + + refetchOnMount: query => { + if (!isAuthenticated) { + return false; + } + + const externalRefetchOnMount = queryOptions.refetchOnMount; + if (typeof externalRefetchOnMount !== 'function') { + return externalRefetchOnMount ?? true; + } + + return externalRefetchOnMount(query); + }, + + retry: (failureCount, error) => { + if (!isAuthenticated) { + return false; + } + + const externalRetry = queryOptions.retry; + if (typeof externalRetry === 'number') { + const normalized = Number.isInteger(externalRetry) + ? Math.max(1, externalRetry) + : DEFAULT_TANSTACK_QUERY_RETRY_COUNT; + + return failureCount < normalized; + } + + if (typeof externalRetry !== 'function') { + // Could use the nullish coalescing operator here, but Prettier made the + // output hard to read + return externalRetry + ? externalRetry + : failureCount < DEFAULT_TANSTACK_QUERY_RETRY_COUNT; + } + + return externalRetry(failureCount, error); + }, + }; + + return useQuery(patchedOptions); +} diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 2aaaab89..904b7705 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -192,6 +192,12 @@ export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root' export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; export { useCoderSdk } from './hooks/useCoderSdk'; export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; +export { useCoderQuery } from './hooks/reactQueryWrappers'; + +/** + * General constants + */ +export { CODER_QUERY_KEY_PREFIX } from './api/queryOptions'; /** * All custom types diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 86ceedcb..cc8c67ad 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -106,7 +106,7 @@ export function getMockQueryClient(): QueryClient { } type MockAuthProps = Readonly< - CoderProviderProps & { + Omit & { auth?: CoderAuth; /** @@ -221,3 +221,24 @@ export async function renderInCoderEnvironment({ await waitFor(() => expect(loadingIndicator).not.toBeInTheDocument()); return renderOutput; } + +type InvertedPromiseResult = Readonly<{ + promise: Promise; + resolve: (value: TData) => void; + reject: (errorReason: TError) => void; +}>; + +export function createInvertedPromise< + TData = unknown, + TError = Error, +>(): InvertedPromiseResult { + let resolve!: (value: TData) => void; + let reject!: (error: TError) => void; + + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + + return { promise, resolve, reject }; +} From 80d6858867de084d6c706db186cfab475214f59d Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Mon, 17 Jun 2024 15:50:28 -0400 Subject: [PATCH 29/33] chore(Coder plugin): Create guide for working with the Coder SDK (#133) * chore: add vendored version of experimental Coder SDK * chore: update CoderClient class to use new SDK * chore: delete mock SDK * fix: improve data hiding for CoderSdk * docs: update typo * wip: commit progress on updating Coder client * wip: commit more progress on updating types * chore: remove valibot type definitions from global constants file * chore: rename mocks file * fix: update type mismatches * wip: commit more update progress * wip: commit progress on updating client/SDK integration * fix: get all tests passing for CoderClient * fix: update UrlSync updates * fix: get all tests passing * chore: update all mock data to use Coder core entity mocks * refactor: improve co-location for useCoderWorkspacesQuery * wip: commit progress on React Query wrappers * fix: add extra helpers to useCoderSdk * wip: add test stubs for useCoderQuery * fix: add queryKey patching to useCoderQuery * fix: only add queryKey prefix if it is missing * fix: make Coder query key prefix an opaque string * refactor: improve ergonomics of useCoderQuery * refactor: clean up query key patching logic * chore: let users disable fallback auth UI * wip: commit progress on tests * chore: update wording for clarity * fix: update import for workspaces card root * chore: get first test passing * chore: add inverted promise helper * fix: make non-authenticated queries fail faster * fix: update tests to make setup easier * wip: get another test passing * chore: finish all initial tests for useCoderQuery * fix: tighten up types for inverted promises * fix: more tightening * fix: make sure queries aren't tried indefinitely by default * wip: commit docs progress * fix: increase granularity for auth fallback behavior * wip: commit more docs progress * fix: establish better boundaries between hooks * wip: commit more progress * wip: more docs progress * fix: split up auth fallback logic into three providers * fix: update example code * wip: commit more progress * fix: update names for auth fallback modes * wip: more progress * fix: remove repetitive wording * wip: more progress * fix: add table of contents header * fix: improve granularity of expired token spy logic * fix: prevent infinite revalidation loop * fix: clean up the cleanup logic * fix: update example code * fix: update header levels * fix: make prop optional * chore: add warning about query client mistakes * wip: finish last code example * fix: update union/intersection mismatch * chore: finish initial version of SDK readme * wip: make placeholders more obvious * fix: add additional properties to hide from SDK * fix: shrink down the API of useCoderSdk * update method name for clarity * chore: removal vestigal endpoint properties * fix: swap public 'SDK' usage with 'API' * fix: remove temp import * fix: update exports for end-types * fix: update query wrapper tests * wip: commit current rewrite progress * fix: update structure of directory readme * wip: commit more docs progress * chore: finish second draft of main README * refactor: rename ejectToken to unlinkToken * refactor: reorganize readme file structure * update details for new versions of README * chore: delete first draft of the README * fix: remove duplicate destructuring * fix: update duplicate exports * fix: update semver message * fix: remove useEffect comparison column * fix: move custom query client into advanced section * fix: remove redundant examples * fix: update hook overview * fix: update formatting for advanced file * fix: regorganize prefix section * chore: finish v3 of reorganization * chore: reorganize text content one last time * chore: group prefix examples * chore: reorganize directory readme * chore: add image of auth fallback * chore: add video of auth functionality --- plugins/backstage-plugin-coder/docs/README.md | 25 +- .../docs/{ => api-reference}/catalog-info.md | 0 .../docs/{ => api-reference}/components.md | 0 .../docs/{ => api-reference}/hooks.md | 0 .../docs/{ => api-reference}/types.md | 0 .../docs/guides/coder-api-advanced.md | 72 +++++ .../docs/guides/coder-api.md | 262 ++++++++++++++++++ .../screenshots/auth-fallback.png | Bin 0 -> 397274 bytes .../src/api/CoderClient.test.ts | 26 +- .../src/api/CoderClient.ts | 40 +-- .../src/api/UrlSync.test.ts | 4 +- .../src/api/queryOptions.ts | 12 +- .../src/api/vendoredSdk/api/api.ts | 2 +- .../src/api/vendoredSdk/index.ts | 6 +- .../CoderAuthForm/CoderAuthDistrustedForm.tsx | 2 +- .../CoderAuthForm/CoderAuthForm.test.tsx | 16 +- .../CoderAuthForm/UnlinkAccountButton.tsx | 4 +- .../CoderProvider/CoderAuthProvider.tsx | 24 +- .../CoderProvider/CoderProvider.test.tsx | 13 +- .../CoderProvider/CoderProvider.tsx | 2 +- .../ExtraActionsButton.test.tsx | 9 +- .../ExtraActionsButton.tsx | 4 +- .../useCoderWorkspacesQuery.ts | 8 +- .../src/hooks/reactQueryWrappers.test.tsx | 39 +-- .../src/hooks/reactQueryWrappers.ts | 12 +- .../hooks/{useCoderSdk.ts => useCoderApi.ts} | 13 +- .../src/hooks/useUrlSync.test.tsx | 6 +- plugins/backstage-plugin-coder/src/plugin.ts | 17 +- .../src/testHelpers/mockBackstageData.ts | 23 +- .../src/testHelpers/setup.tsx | 11 +- 30 files changed, 514 insertions(+), 138 deletions(-) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/catalog-info.md (100%) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/components.md (100%) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/hooks.md (100%) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/types.md (100%) create mode 100644 plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md create mode 100644 plugins/backstage-plugin-coder/docs/guides/coder-api.md create mode 100644 plugins/backstage-plugin-coder/screenshots/auth-fallback.png rename plugins/backstage-plugin-coder/src/hooks/{useCoderSdk.ts => useCoderApi.ts} (51%) diff --git a/plugins/backstage-plugin-coder/docs/README.md b/plugins/backstage-plugin-coder/docs/README.md index 1aac4a05..95019233 100644 --- a/plugins/backstage-plugin-coder/docs/README.md +++ b/plugins/backstage-plugin-coder/docs/README.md @@ -1,11 +1,22 @@ -# Plugin API Reference – Coder for Backstage +# Documentation Directory – `backstage-plugin-coder` v0.3.0 -For users who need more information about how to extend and modify the Coder plugin. For general setup, please see our main [README](../README.md). +This document lists core information for the Backstage Coder plugin. It is intended for users who have already set up the plugin and are looking to take it further. -All documentation reflects version `v0.2.0` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. +For general setup, please see our [main README](../README.md). -## Documentation directory +## Documentation listing -- [Components](./components.md) -- [Custom React hooks](./hooks.md) -- [Important types](./types.md) +### Guides + +- [Using the Coder API from Backstage](./guides/coder-api.md) + - [Advanced use cases for the Coder API](./guides//coder-api-advanced.md) + +### API reference + +- [Components](./api-reference/components.md) +- [Custom React hooks](./api-reference/hooks.md) +- [Important types](./api-reference/types.md) + +## Notes about semantic versioning + +We fully intend to follow semantic versioning with the Coder plugin for Backstage. Expect some pain points as we figure out the right abstractions needed to hit version 1, but we will try to minimize breaking changes as much as possible as the library gets ironed out. diff --git a/plugins/backstage-plugin-coder/docs/catalog-info.md b/plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/catalog-info.md rename to plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md diff --git a/plugins/backstage-plugin-coder/docs/components.md b/plugins/backstage-plugin-coder/docs/api-reference/components.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/components.md rename to plugins/backstage-plugin-coder/docs/api-reference/components.md diff --git a/plugins/backstage-plugin-coder/docs/hooks.md b/plugins/backstage-plugin-coder/docs/api-reference/hooks.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/hooks.md rename to plugins/backstage-plugin-coder/docs/api-reference/hooks.md diff --git a/plugins/backstage-plugin-coder/docs/types.md b/plugins/backstage-plugin-coder/docs/api-reference/types.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/types.md rename to plugins/backstage-plugin-coder/docs/api-reference/types.md diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md new file mode 100644 index 00000000..fb90ebe6 --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md @@ -0,0 +1,72 @@ +# Working with the Coder API - advanced use cases + +This guide covers some more use cases that you can leverage for more advanced configuration of the Coder API from within Backstage. + +## Changing fallback auth component behavior + +By default, `CoderProvider` is configured to display a fallback auth UI component when two cases are true: + +1. The user is not authenticated +2. There are no official Coder components are being rendered to the screen. + +The Coder auth fallback UI + +All official Coder plugin components are configured to let the user add auth information if the user isn't already authenticated, so the fallback component only displays when there would be no other way to add the information. + +However, depending on your use cases, `CoderProvider` can be configured to change how it displays the fallback, based on the value of the `fallbackAuthUiMode` prop. + +```tsx + + + +``` + +There are three values that can be set for the mode: + +- `restrained` (default) - The auth fallback will only display if the user is not authenticated, and there would be no other way for the user to add their auth info. +- `assertive` - The auth fallback will always display when the user is not authenticated, regardless of what Coder component are on-screen. But the fallback will **not** appear if the user is authenticated. +- `hidden` - The auth fallback will never appear under any circumstances. Useful if you want to create entirely custom components and don't mind wiring your auth logic manually via `useCoderAuth`. + +## Connecting a custom query client to the Coder plugin + +By default, the Coder plugin uses and manages its own query client. This works perfectly well if you aren't using React Query for any other purposes, but if you are using it throughout your Backstage deployment, it can cause issues around redundant state (e.g., not all cached data being vacated when the user logs out). + +To prevent this, you will need to do two things: + +1. Pass in your custom React Query query client into the `CoderProvider` component +2. "Group" your queries with the Coder query key prefix + +```tsx +const yourCustomQueryClient = new QueryClient(); + + + +; + +// Ensure that all queries have the correct query key prefix +import { useQuery } from '@tanstack/react-react-query'; +import { + CODER_QUERY_KEY_PREFIX, + useCoderQuery, +} from '@coder/backstage-plugin-coder'; + +function CustomComponent() { + const query1 = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => { + // Get workspaces here + }, + }); + + // useCoderQuery automatically prefixes all query keys with + // CODER_QUERY_KEY_PREFIX if it's not already the first value of the array + const query2 = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: () => { + // Get workspaces here + }, + }); + + return
    Main component content
    ; +} +``` diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md new file mode 100644 index 00000000..04e8d10d --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -0,0 +1,262 @@ +# Coder API - Quick-start guide + +## Overview + +The Coder plugin makes it easy to bring the entire Coder API into your Backstage deployment. This guide covers how to get it set up so that you can start accessing Coder from Backstage. + +Note: this covers the main expected use cases with the plugin. For more information and options on customizing your Backstage deployment further, see our [Advanced API guide](./coder-api-advanced.md). + +### Before you begin + +Please ensure that you have the Coder plugin fully installed before proceeding. You can find instructions for getting up and running in [our main README](../../README.md). + +### Important hooks for using the Coder API + +The Coder plugin exposes three (soon to be four) main hooks for accessing Coder plugin state and making queries/mutations + +- `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session from within Backstage. + + ```tsx + function SessionTokenInputForm() { + const [sessionTokenDraft, setSessionTokenDraft] = useState(''); + const coderAuth = useCoderAuth(); + + const onSubmit = (event: FormEvent) => { + coderAuth.registerNewToken(sessionToken); + setSessionTokenDraft(''); + }; + + return ( + + + + ); + } + ``` + +- `useCoderQuery` - Makes it simple to query data from the Coder API and share it throughout your application. + + ```tsx + function WorkspacesList() { + // Return type matches the return type of React Query's useQuerys + const workspacesQuery = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: ({ coderApi }) => coderApi.getWorkspaces({ limit: 5 }), + }); + } + ``` + +- `useCoderMutation` (coming soon) - Makes it simple to mutate data via the Coder API. +- `useCoderApi` - Exposes an object with all available Coder API methods. None of the state in this object is tied to React render logic - it can be treated as a "function bucket". Once `useCoderMutation` is available, the main value of this hook will be as an escape hatch in the rare situations where `useCoderQuery` and `useCoderMutation` don't meet your needs. Under the hood, both `useCoderQuery` and `useCoderMutation` receive their `coderApi` context value from this hook. + + ```tsx + function HealthCheckComponent() { + const coderApi = useCoderApi(); + + const processWorkspaces = async () => { + const workspacesResponse = await coderApi.getWorkspaces({ + limit: 10, + }); + + processHealthChecks(workspacesResponse.workspaces); + }; + } + ``` + +Internally, the Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview). In fact, `useCoderQuery` and `useCoderMutation` are simply wrappers over `useQuery` and `useMutation`. Both simplify the process of wiring up the hooks' various properties to the Coder auth, while exposing a more convenient way of accessing the Coder API object. + +If you ever need to coordinate queries and mutations, you can use `useQueryClient` from React Query - no custom plugin-specific hook needed. + +The bottom of this document has examples of both queries and mutations. + +### Grouping queries with the Coder query key prefix + +The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries. `useCoderQuery` automatically injects this value into all its `queryKey` arrays. However, if you need to escape out with `useQuery`, you can import the constant and manually include it as the first value of your query key. + +In addition, all official Coder plugin components use this prefix internally. + +```tsx +// All grouped queries can be invalidated at once from the query client +const queryClient = useQueryClient(); +const invalidateAllCoderQueries = () => { + queryClient.invalidateQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX], + }); +}; + +// The prefix is only needed when NOT using useCoderQuery +const customQuery = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => { + // Your custom API logic + }, +}); + +// When the user unlinks their session token, all queries grouped under +// CODER_QUERY_KEY_PREFIX are vacated from the active query cache +function LogOutButton() { + const { unlinkToken } = useCoderAuth(); + + return ( + + ); +} +``` + +## Recommendations for accessing the API + +1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query (which includes pausing queries if the user is not authenticated). It also lets you access the Coder API via its query function. `useQuery` works as an escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. +2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. + +We highly recommend **not** fetching with `useState` + `useEffect`, or with `useAsync`. Both face performance issues when trying to share state. See [ui.dev](https://www.ui.dev/)'s wonderful [_The Story of React Query_ video](https://www.youtube.com/watch?v=OrliU0e09io) for more info on some of the problems they face. + +\* `useCoderMutation` can be used instead of all three once that hook is available. + +### Comparing query caching strategies + +| | `useAsync` | `useQuery` | `useCoderQuery` | +| ------------------------------------------------------------------ | ---------- | ---------- | --------------- | +| Automatically handles race conditions | ✅ | ✅ | ✅ | +| Can retain state after component unmounts | 🚫 | ✅ | ✅ | +| Easy, on-command query invalidation | 🚫 | ✅ | ✅ | +| Automatic retry logic when a query fails | 🚫 | ✅ | ✅ | +| Less need to fight dependency arrays | 🚫 | ✅ | ✅ | +| Easy to share state for sibling components | 🚫 | ✅ | ✅ | +| Pre-wired to Coder auth logic | 🚫 | 🚫 | ✅ | +| Can consume Coder API directly from query function | 🚫 | 🚫 | ✅ | +| Automatically groups Coder-related queries by prefixing query keys | 🚫 | 🚫 | ✅ | + +## Authentication + +All API calls to **any** of the Coder API functions will fail if you have not authenticated yet. Authentication can be handled via any of the official Coder components that can be imported via the plugin. However, if there are no Coder components on the screen, the `CoderProvider` component will automatically\* inject a fallback auth button for letting the user add their auth info. + +https://github.com/coder/backstage-plugins/assets/28937484/0ece4410-36fc-4b32-9223-66f35953eeab + +Once the user has been authenticated, all Coder API functions will become available. When the user unlinks their auth token (effectively logging out), all cached queries that start with `CODER_QUERY_KEY_PREFIX` will automatically be vacated. + +\* This behavior can be disabled. Please see our [advanced API guide](./coder-api-advanced.md) for more information. + +## Component examples + +Here are some full code examples showcasing patterns you can bring into your own codebase. + +Note: To keep the examples simple, none of them contain any CSS styling or MUI components. + +### Displaying recent audit logs + +```tsx +import React from 'react'; +import { useCoderQuery } from '@coder/backstage-plugin-coder'; + +function RecentAuditLogsList() { + const auditLogsQuery = useCoderQuery({ + queryKey: ['audits', 'logs'], + queryFn: ({ coderApi }) => coderApi.getAuditLogs({ limit: 10 }), + }); + + return ( + <> + {auditLogsQuery.isLoading &&

    Loading…

    } + {auditLogsQuery.error instanceof Error && ( +

    Encountered the following error: {auditLogsQuery.error.message}

    + )} + + {auditLogsQuery.data !== undefined && ( +
      + {auditLogsQuery.data.audit_logs.map(log => ( +
    • {log.description}
    • + ))} +
    + )} + + ); +} +``` + +## Creating a new workspace + +Note: this example showcases how to perform mutations with `useMutation`. The example will be updated once `useCoderMutation` is available. + +```tsx +import React, { type FormEvent, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + type CreateWorkspaceRequest, + CODER_QUERY_KEY_PREFIX, + useCoderQuery, + useCoderApi, +} from '@coder/backstage-plugin-coder'; + +export function WorkspaceCreationForm() { + const [newWorkspaceName, setNewWorkspaceName] = useState(''); + const coderApi = useCoderSdk(); + const queryClient = useQueryClient(); + + const currentUserQuery = useCoderQuery({ + queryKey: ['currentUser'], + queryFn: coderApi.getAuthenticatedUser, + }); + + const workspacesQuery = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: coderApi.getWorkspaces, + }); + + const createWorkspaceMutation = useMutation({ + mutationFn: (payload: CreateWorkspaceRequest) => { + if (currentUserQuery.data === undefined) { + throw new Error( + 'Cannot create workspace without data for current user', + ); + } + + const { organization_ids, id: userId } = currentUserQuery.data; + return coderApi.createWorkspace(organization_ids[0], userId, payload); + }, + }); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + + // If the mutation fails, useMutation will expose the error in the UI via + // its own exposed properties + await createWorkspaceMutation.mutateAsync({ + name: newWorkspaceName, + }); + + setNewWorkspaceName(''); + queryClient.invalidateQueries({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + }); + }; + + return ( + <> + {createWorkspaceMutation.isSuccess && ( +

    + Workspace {createWorkspaceMutation.data.name} created successfully! +

    + )} + +
    +
    + Required fields + + +
    + + +
    + + ); +} +``` diff --git a/plugins/backstage-plugin-coder/screenshots/auth-fallback.png b/plugins/backstage-plugin-coder/screenshots/auth-fallback.png new file mode 100644 index 0000000000000000000000000000000000000000..d5b817ccdfa7a3512dd3d73602661eb48906c664 GIT binary patch literal 397274 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

    $WeS|F$MO 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 Y(zoV { +describe(`${CoderClientWrapper.name}`, () => { describe('syncToken functionality', () => { it('Will load the provided token into the client if it is valid', async () => { - const client = new CoderClient({ apis: getConstructorApis() }); + const client = new CoderClientWrapper({ apis: getConstructorApis() }); const syncResult = await client.syncToken(mockCoderAuthToken); expect(syncResult).toBe(true); @@ -50,12 +50,12 @@ describe(`${CoderClient.name}`, () => { }), ); - await client.sdk.getAuthenticatedUser(); + await client.api.getAuthenticatedUser(); expect(serverToken).toBe(mockCoderAuthToken); }); it('Will NOT load the provided token into the client if it is invalid', async () => { - const client = new CoderClient({ apis: getConstructorApis() }); + const client = new CoderClientWrapper({ apis: getConstructorApis() }); const syncResult = await client.syncToken('Definitely not valid'); expect(syncResult).toBe(false); @@ -68,12 +68,12 @@ describe(`${CoderClient.name}`, () => { }), ); - await client.sdk.getAuthenticatedUser(); + await client.api.getAuthenticatedUser(); expect(serverToken).toBe(null); }); it('Will propagate any other error types to the caller', async () => { - const client = new CoderClient({ + const client = new CoderClientWrapper({ // Setting the timeout to 0 will make requests instantly fail from the // next microtask queue tick requestTimeoutMs: 0, @@ -96,13 +96,13 @@ describe(`${CoderClient.name}`, () => { }); }); - // Eventually the Coder SDK is going to get too big to test every single + // Eventually the Coder API is going to get too big to test every single // function. Focus tests on the functionality specifically being patched in // for Backstage - describe('Coder SDK', () => { + describe('Coder API', () => { it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => { const apis = getConstructorApis(); - const client = new CoderClient({ + const client = new CoderClientWrapper({ apis, initialToken: mockCoderAuthToken, }); @@ -126,7 +126,7 @@ describe(`${CoderClient.name}`, () => { }), ); - const { workspaces } = await client.sdk.getWorkspaces({ + const { workspaces } = await client.api.getWorkspaces({ q: 'owner:me', limit: 0, }); @@ -142,12 +142,12 @@ describe(`${CoderClient.name}`, () => { }); it('Lets the user search for workspaces by repo URL', async () => { - const client = new CoderClient({ + const client = new CoderClientWrapper({ initialToken: mockCoderAuthToken, apis: getConstructorApis(), }); - const { workspaces } = await client.sdk.getWorkspacesByRepo( + const { workspaces } = await client.api.getWorkspacesByRepo( { q: 'owner:me' }, mockCoderWorkspacesConfig, ); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 4c5333dd..c760f1d2 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -7,23 +7,23 @@ import { CODER_API_REF_ID_PREFIX } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import { - type CoderSdk, + type CoderApi, type User, type Workspace, type WorkspacesRequest, type WorkspacesResponse, - makeCoderSdk, + createCoderApi, } from './vendoredSdk'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; /** - * A version of the main Coder SDK API, with additional Backstage-specific + * A version of the main Coder API, with additional Backstage-specific * methods and properties. */ -export type BackstageCoderSdk = Readonly< - CoderSdk & { +export type BackstageCoderApi = Readonly< + CoderApi & { getWorkspacesByRepo: ( request: WorkspacesRequest, config: CoderWorkspacesConfig, @@ -31,8 +31,8 @@ export type BackstageCoderSdk = Readonly< } >; -type CoderClientApi = Readonly<{ - sdk: BackstageCoderSdk; +type CoderClientWrapperApi = Readonly<{ + api: BackstageCoderApi; /** * Validates a new token, and loads it only if it is valid. @@ -75,7 +75,7 @@ type RequestInterceptor = ( config: RequestConfig, ) => RequestConfig | Promise; -export class CoderClient implements CoderClientApi { +export class CoderClientWrapper implements CoderClientWrapperApi { private readonly urlSync: UrlSync; private readonly identityApi: IdentityApi; @@ -84,7 +84,7 @@ export class CoderClient implements CoderClientApi { private readonly trackedEjectionIds: Set; private loadedSessionToken: string | undefined; - readonly sdk: BackstageCoderSdk; + readonly api: BackstageCoderApi; constructor(inputs: ConstructorInputs) { const { @@ -100,7 +100,7 @@ export class CoderClient implements CoderClientApi { this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); - this.sdk = this.createBackstageCoderSdk(); + this.api = this.createBackstageCoderApi(); this.addBaseRequestInterceptors(); } @@ -108,7 +108,7 @@ export class CoderClient implements CoderClientApi { requestInterceptor: RequestInterceptor, errorInterceptor?: (error: unknown) => unknown, ): number { - const axios = this.sdk.getAxiosInstance(); + const axios = this.api.getAxiosInstance(); const ejectionId = axios.interceptors.request.use( requestInterceptor, errorInterceptor, @@ -121,7 +121,7 @@ export class CoderClient implements CoderClientApi { private removeRequestInterceptorById(ejectionId: number): boolean { // Even if we somehow pass in an ID that hasn't been associated with the // Axios instance, that's a noop. No harm in calling method no matter what - const axios = this.sdk.getAxiosInstance(); + const axios = this.api.getAxiosInstance(); axios.interceptors.request.eject(ejectionId); if (!this.trackedEjectionIds.has(ejectionId)) { @@ -181,11 +181,11 @@ export class CoderClient implements CoderClientApi { this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } - private createBackstageCoderSdk(): BackstageCoderSdk { - const baseSdk = makeCoderSdk(); + private createBackstageCoderApi(): BackstageCoderApi { + const baseApi = createCoderApi(); - const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { - const workspacesRes = await baseSdk.getWorkspaces(request); + const getWorkspaces: (typeof baseApi)['getWorkspaces'] = async request => { + const workspacesRes = await baseApi.getWorkspaces(request); const remapped = await this.remapWorkspaceIconUrls( workspacesRes.workspaces, ); @@ -214,7 +214,7 @@ export class CoderClient implements CoderClientApi { q: appendParamToQuery(request.q, key, stringUrl), }; - return baseSdk.getWorkspaces(patchedRequest); + return baseApi.getWorkspaces(patchedRequest); }), ); @@ -237,7 +237,7 @@ export class CoderClient implements CoderClientApi { }; return { - ...baseSdk, + ...baseApi, getWorkspaces, getWorkspacesByRepo, }; @@ -312,7 +312,7 @@ export class CoderClient implements CoderClientApi { // Actual request type doesn't matter; just need to make some kind of // dummy request. Should favor requests that all users have access to and // that don't require request bodies - const dummyUser = await this.sdk.getAuthenticatedUser(); + const dummyUser = await this.api.getAuthenticatedUser(); // Most of the time, we're going to trust the types returned back from the // server without doing any type-checking, but because this request does @@ -376,6 +376,6 @@ function assertValidUser(value: unknown): asserts value is User { } } -export const coderClientApiRef = createApiRef({ +export const coderClientWrapperApiRef = createApiRef({ id: `${CODER_API_REF_ID_PREFIX}.coder-client`, }); diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 62001e4e..00e86a7c 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -5,7 +5,7 @@ import { getMockDiscoveryApi, mockBackstageAssetsEndpoint, mockBackstageUrlRoot, - mockBackstageApiEndpointWithoutSdkPath, + mockBackstageApiEndpointWithoutVersionSuffix, } from '../testHelpers/mockBackstageData'; // Tests have to assume that DiscoveryApi and ConfigApi will always be in sync, @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageApiEndpointWithoutSdkPath, + apiRoute: mockBackstageApiEndpointWithoutVersionSuffix, assetsRoute: mockBackstageAssetsEndpoint, }); }); diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 6bfbd800..b622e415 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,7 +1,7 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import type { Workspace, WorkspacesRequest } from './vendoredSdk'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import type { BackstageCoderSdk } from './CoderClient'; +import type { BackstageCoderApi } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; // Making the type more broad to hide some implementation details from the end @@ -47,13 +47,13 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { type WorkspacesFetchInputs = Readonly<{ auth: CoderAuth; - sdk: BackstageCoderSdk; + api: BackstageCoderApi; coderQuery: string; }>; export function workspaces({ auth, - sdk, + api, coderQuery, }: WorkspacesFetchInputs): UseQueryOptions { const enabled = auth.isAuthenticated; @@ -64,7 +64,7 @@ export function workspaces({ keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { - const res = await sdk.getWorkspaces({ + const res = await api.getWorkspaces({ q: coderQuery, limit: 0, }); @@ -82,7 +82,7 @@ type WorkspacesByRepoFetchInputs = Readonly< export function workspacesByRepo({ coderQuery, - sdk, + api, auth, workspacesConfig, }: WorkspacesByRepoFetchInputs): UseQueryOptions { @@ -98,7 +98,7 @@ export function workspacesByRepo({ refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; - const res = await sdk.getWorkspacesByRepo(request, workspacesConfig); + const res = await api.getWorkspacesByRepo(request, workspacesConfig); return res.workspaces; }, }; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts index bf293267..6877a614 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts @@ -1894,7 +1894,7 @@ function getConfiguredAxiosInstance(): AxiosInstance { } else { // Do not write error logs if we are in a FE unit test. if (process.env.JEST_WORKER_ID === undefined) { - // eslint-disable-next-line no-console -- Function should never run in vendored version of SDK + // eslint-disable-next-line no-console -- Function should never run in vendored version of API console.error('CSRF token not found'); } } diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts index f8451116..18fc9eae 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -29,8 +29,8 @@ type PropertyToHide = // with the extra properties omitted). But because classes are wonky and exist // as both runtime values and types, it didn't seem possible, even with things // like class declarations. Making a new function is good enough for now, though -export type CoderSdk = Omit; -export function makeCoderSdk(): CoderSdk { +export type CoderApi = Omit; +export function createCoderApi(): CoderApi { const api = new Api(); - return api as CoderSdk; + return api as CoderApi; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index a37c1916..3f58804d 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -36,7 +36,7 @@ export const CoderAuthDistrustedForm = () => {

    Unable to verify token authenticity. Please check your internet - connection, or try ejecting the token. + connection, or try unlinking the token.

    diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index 95ce2993..79b263ca 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -16,12 +16,12 @@ type RenderInputs = Readonly<{ }>; async function renderAuthWrapper({ authStatus }: RenderInputs) { - const ejectToken = jest.fn(); + const unlinkToken = jest.fn(); const registerNewToken = jest.fn(); const auth: CoderAuth = { ...mockAuthStates[authStatus], - ejectToken, + unlinkToken, registerNewToken, }; @@ -40,7 +40,7 @@ async function renderAuthWrapper({ authStatus }: RenderInputs) { , ); - return { ...renderOutput, ejectToken, registerNewToken }; + return { ...renderOutput, unlinkToken, registerNewToken }; } describe(`${CoderAuthForm.name}`, () => { @@ -70,18 +70,18 @@ describe(`${CoderAuthForm.name}`, () => { } }); - it('Lets the user eject the current token', async () => { - const { ejectToken } = await renderAuthWrapper({ + it('Lets the user unlink the current token', async () => { + const { unlinkToken } = await renderAuthWrapper({ authStatus: 'distrusted', }); const user = userEvent.setup(); - const ejectButton = await screen.findByRole('button', { + const unlinkButton = await screen.findByRole('button', { name: /Unlink Coder account/, }); - await user.click(ejectButton); - expect(ejectToken).toHaveBeenCalled(); + await user.click(unlinkButton); + expect(unlinkToken).toHaveBeenCalled(); }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx index 63b9fdd0..efc23329 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx @@ -19,7 +19,7 @@ export function UnlinkAccountButton({ ...delegatedProps }: Props) { const styles = useStyles(); - const { ejectToken } = useInternalCoderAuth(); + const { unlinkToken } = useInternalCoderAuth(); return ( { - ejectToken(); + unlinkToken(); onClick?.(event); }} {...delegatedProps} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 33b5bc0a..9b4eb549 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -24,7 +24,7 @@ import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; -import { coderClientApiRef } from '../../api/CoderClient'; +import { coderClientWrapperApiRef } from '../../api/CoderClient'; import { CoderLogo } from '../CoderLogo'; import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; @@ -67,7 +67,7 @@ export type CoderAuth = Readonly< AuthState & { isAuthenticated: boolean; registerNewToken: (newToken: string) => void; - ejectToken: () => void; + unlinkToken: () => void; } >; @@ -91,7 +91,7 @@ function useAuthState(): CoderAuth { const [readonlyInitialAuthToken] = useState(authToken); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const coderClient = useApi(coderClientApiRef); + const coderClient = useApi(coderClientWrapperApiRef); const queryIsEnabled = authToken !== ''; const authValidityQuery = useQuery({ @@ -149,12 +149,14 @@ function useAuthState(): CoderAuth { // Pseudo-mutex; makes sure that if we get a bunch of errors, only one // revalidation will be processed at a time - let isRevalidatingToken = false; + let isRevalidating = false; const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { const queryError = event.query.state.error; + const shouldRevalidate = - !isRevalidatingToken && + isAuthenticated && + !isRevalidating && BackstageHttpError.isInstance(queryError) && queryError.status === 401; @@ -162,9 +164,9 @@ function useAuthState(): CoderAuth { return; } - isRevalidatingToken = true; + isRevalidating = true; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); - isRevalidatingToken = false; + isRevalidating = false; }; const queryCache = queryClient.getQueryCache(); @@ -178,7 +180,7 @@ function useAuthState(): CoderAuth { } }, []); - const ejectToken = useCallback(() => { + const unlinkToken = useCallback(() => { setAuthToken(''); window.localStorage.removeItem(TOKEN_STORAGE_KEY); queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); @@ -188,7 +190,7 @@ function useAuthState(): CoderAuth { ...authState, isAuthenticated, registerNewToken, - ejectToken, + unlinkToken, }; } @@ -275,7 +277,7 @@ export function useInternalCoderAuth(): CoderAuth { /** * Exposes Coder auth state to the rest of the UI. */ -// This hook should only be used by end users trying to use the Coder SDK inside +// This hook should only be used by end users trying to use the Coder API inside // Backstage. The hook is renamed on final export to avoid confusion export function useEndUserCoderAuth(): CoderAuth { const authContextValue = useContext(AuthStateContext); @@ -625,7 +627,7 @@ type AuthFallbackProvider = FC< // Matches each behavior for the fallback auth UI to a specific provider. This // is screwy code, but by doing this, we ensure that if the user chooses not to -// have a dynamic auth fallback UI, their app will have far less tracking logic, +// have dynamic a auth fallback UI, their app will have far less tracking logic, // meaning less performance overhead and fewer re-renders from something the // user isn't even using const fallbackProviders = { diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 382917d8..b58af930 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -27,7 +27,10 @@ import { renderHookAsCoderEntity, } from '../../testHelpers/setup'; import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; -import { CoderClient, coderClientApiRef } from '../../api/CoderClient'; +import { + CoderClientWrapper, + coderClientWrapperApiRef, +} from '../../api/CoderClient'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -66,7 +69,7 @@ describe(`${CoderProvider.name}`, () => { apis: { discoveryApi, configApi }, }); - const coderClientApi = new CoderClient({ + const coderClientApi = new CoderClientWrapper({ apis: { urlSync, identityApi }, }); @@ -80,7 +83,7 @@ describe(`${CoderProvider.name}`, () => { [discoveryApiRef, discoveryApi], [urlSyncApiRef, urlSync], - [coderClientApiRef, coderClientApi], + [coderClientWrapperApiRef, coderClientApi], ]} > { }); }; - it('Should let the user eject their auth token', async () => { + it('Should let the user unlink their auth token', async () => { const { result } = renderUseCoderAuth(); act(() => result.current.registerNewToken(mockCoderAuthToken)); @@ -109,7 +112,7 @@ describe(`${CoderProvider.name}`, () => { ); }); - act(() => result.current.ejectToken()); + act(() => result.current.unlinkToken()); expect(result.current).toEqual( expect.objectContaining>({ diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index fd562851..079e1f38 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -45,8 +45,8 @@ const defaultClient = new QueryClient({ export const CoderProvider = ({ children, appConfig, - queryClient = defaultClient, fallbackAuthUiMode = 'restrained', + queryClient = defaultClient, }: CoderProviderProps) => { return ( diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx index 008d931a..d170db36 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx @@ -29,8 +29,11 @@ type RenderInputs = Readonly<{ }>; async function renderButton({ buttonText }: RenderInputs) { - const ejectToken = jest.fn(); - const auth: CoderAuth = { ...mockAuthStates.authenticated, ejectToken }; + const unlinkToken = jest.fn(); + const auth: CoderAuth = { + ...mockAuthStates.authenticated, + unlinkToken: unlinkToken, + }; /** * Pretty sure there has to be a more elegant and fault-tolerant way of @@ -58,7 +61,7 @@ async function renderButton({ buttonText }: RenderInputs) { return { ...renderOutput, button: screen.getByRole('button', { name: new RegExp(buttonText) }), - unlinkCoderAccount: ejectToken, + unlinkCoderAccount: unlinkToken, refreshWorkspaces: refetch, }; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 3d9dbcf6..a6ccfb19 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -102,7 +102,7 @@ export const ExtraActionsButton = ({ const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useInternalCoderAuth(); + const { unlinkToken } = useInternalCoderAuth(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); @@ -178,7 +178,7 @@ export const ExtraActionsButton = ({ { - ejectToken(); + unlinkToken(); closeMenu(); }} > diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts index 5f82e6b7..305a5bab 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../../api/queryOptions'; import type { CoderWorkspacesConfig } from '../../hooks/useCoderWorkspacesConfig'; -import { useCoderSdk } from '../../hooks/useCoderSdk'; +import { useCoderApi } from '../../hooks/useCoderApi'; import { useInternalCoderAuth } from '../../components/CoderProvider'; type QueryInput = Readonly<{ @@ -13,13 +13,13 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const sdk = useCoderSdk(); + const api = useCoderApi(); const auth = useInternalCoderAuth(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ auth, sdk, coderQuery, workspacesConfig }) - : workspaces({ auth, sdk, coderQuery }); + ? workspacesByRepo({ auth, api, coderQuery, workspacesConfig }) + : workspaces({ auth, api, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 83309a08..65029704 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -25,7 +25,7 @@ import { getMockQueryClient, } from '../testHelpers/setup'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; -import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; +import { CODER_QUERY_KEY_PREFIX } from '../plugin'; import { mockWorkspacesList } from '../testHelpers/mockCoderPluginData'; type RenderUseQueryOptions< @@ -52,11 +52,11 @@ async function renderCoderQuery< } = options; let latestRegisterNewToken!: CoderAuth['registerNewToken']; - let latestEjectToken!: CoderAuth['ejectToken']; + let latestUnlinkToken!: CoderAuth['unlinkToken']; const AuthEscapeHatch = () => { const auth = useEndUserCoderAuth(); latestRegisterNewToken = auth.registerNewToken; - latestEjectToken = auth.ejectToken; + latestUnlinkToken = auth.unlinkToken; return null; }; @@ -91,15 +91,15 @@ async function renderCoderQuery< return act(() => latestRegisterNewToken(mockCoderAuthToken)); }; - const ejectToken = () => { - return act(() => latestEjectToken()); + const unlinkToken = () => { + return act(() => latestUnlinkToken()); }; if (authenticateOnMount) { registerMockToken(); } - return { ...renderOutput, registerMockToken, ejectToken }; + return { ...renderOutput, registerMockToken, unlinkToken }; } describe(`${useCoderQuery.name}`, () => { @@ -113,14 +113,17 @@ describe(`${useCoderQuery.name}`, () => { */ describe('Hook functionality', () => { it('Disables requests while user is not authenticated', async () => { - const { result, registerMockToken, ejectToken } = await renderCoderQuery({ - authenticateOnMount: false, - queryOptions: { - queryKey: ['workspaces'], - queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), - select: response => response.workspaces, + const { result, registerMockToken, unlinkToken } = await renderCoderQuery( + { + authenticateOnMount: false, + queryOptions: { + queryKey: ['workspaces'], + queryFn: ({ coderApi: api }) => + api.getWorkspaces({ q: 'owner:me' }), + select: response => response.workspaces, + }, }, - }); + ); expect(result.current.isLoading).toBe(true); registerMockToken(); @@ -131,7 +134,7 @@ describe(`${useCoderQuery.name}`, () => { expect(result.current.data?.length).toBeGreaterThan(0); }); - ejectToken(); + unlinkToken(); await waitFor(() => expect(result.current.isLoading).toBe(true)); }); @@ -181,7 +184,7 @@ describe(`${useCoderQuery.name}`, () => { }); it('Disables everything when the user unlinks their access token', async () => { - const { result, ejectToken } = await renderCoderQuery({ + const { result, unlinkToken } = await renderCoderQuery({ queryOptions: { queryKey: ['workspaces'], queryFn: () => Promise.resolve(mockWorkspacesList), @@ -198,7 +201,7 @@ describe(`${useCoderQuery.name}`, () => { ); }); - ejectToken(); + unlinkToken(); await waitFor(() => { expect(result.current).toEqual( @@ -226,7 +229,7 @@ describe(`${useCoderQuery.name}`, () => { const { promise, reject } = createInvertedPromise(); const queryFn = jest.fn(() => promise); - const { ejectToken } = await renderCoderQuery({ + const { unlinkToken } = await renderCoderQuery({ queryOptions: { queryFn, queryKey: ['blah'], @@ -238,7 +241,7 @@ describe(`${useCoderQuery.name}`, () => { }); await waitFor(() => expect(queryFn).toHaveBeenCalled()); - ejectToken(); + unlinkToken(); queryFn.mockRestore(); act(() => reject(new Error("Don't feel like giving you data today"))); diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 6dff0240..95dcdffd 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -1,6 +1,6 @@ /** * @file Defines a couple of wrappers over React Query/Tanstack Query that make - * it easier to use the Coder SDK within UI logic. + * it easier to use the Coder API within UI logic. * * These hooks are designed 100% for end-users, and should not be used * internally. Use useEndUserCoderAuth when working with auth logic within these @@ -25,12 +25,12 @@ import { import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; import { useEndUserCoderAuth } from '../components/CoderProvider'; import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; -import { useCoderSdk } from './useCoderSdk'; -import type { BackstageCoderSdk } from '../api/CoderClient'; +import { useCoderApi } from './useCoderApi'; +import type { BackstageCoderApi } from '../api/CoderClient'; export type CoderQueryFunctionContext = QueryFunctionContext & { - sdk: BackstageCoderSdk; + coderApi: BackstageCoderApi; }; export type CoderQueryFunction< @@ -63,7 +63,7 @@ export function useCoderQuery< ): UseQueryResult { const queryClient = useQueryClient(); const { isAuthenticated } = useEndUserCoderAuth(); - const sdk = useCoderSdk(); + const coderApi = useCoderApi(); let patchedQueryKey = queryOptions.queryKey; if ( @@ -98,7 +98,7 @@ export function useCoderQuery< throw new Error('Cannot complete request - user is not authenticated'); } - return queryOptions.queryFn({ ...context, sdk }); + return queryOptions.queryFn({ ...context, coderApi }); }, refetchInterval: (data, query) => { diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderApi.ts similarity index 51% rename from plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts rename to plugins/backstage-plugin-coder/src/hooks/useCoderApi.ts index 7b7017a1..962f009c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderApi.ts @@ -1,13 +1,16 @@ /** - * @file This defines the general helper for accessing the Coder SDK from + * @file This defines the general helper for accessing the Coder API from * Backstage in a type-safe way. * * This hook is meant to be used both internally AND externally. */ import { useApi } from '@backstage/core-plugin-api'; -import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; +import { + type BackstageCoderApi, + coderClientWrapperApiRef, +} from '../api/CoderClient'; -export function useCoderSdk(): BackstageCoderSdk { - const { sdk } = useApi(coderClientApiRef); - return sdk; +export function useCoderApi(): BackstageCoderApi { + const { api } = useApi(coderClientWrapperApiRef); + return api; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index 90cac33d..2662b1e6 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -8,11 +8,11 @@ import { mockBackstageAssetsEndpoint, mockBackstageUrlRoot, getMockConfigApi, - mockBackstageApiEndpointWithoutSdkPath, + mockBackstageApiEndpointWithoutVersionSuffix, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageApiEndpointWithoutSdkPath; + let proxyEndpoint: string = mockBackstageApiEndpointWithoutVersionSuffix; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -53,7 +53,7 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageApiEndpointWithoutSdkPath, + apiRoute: mockBackstageApiEndpointWithoutVersionSuffix, }, }), ); diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 904b7705..d165c36f 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -8,7 +8,10 @@ import { } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; import { UrlSync, urlSyncApiRef } from './api/UrlSync'; -import { CoderClient, coderClientApiRef } from './api/CoderClient'; +import { + CoderClientWrapper, + coderClientWrapperApiRef, +} from './api/CoderClient'; export const coderPlugin = createPlugin({ id: 'coder', @@ -27,13 +30,13 @@ export const coderPlugin = createPlugin({ }, }), createApiFactory({ - api: coderClientApiRef, + api: coderClientWrapperApiRef, deps: { urlSync: urlSyncApiRef, identityApi: identityApiRef, }, factory: ({ urlSync, identityApi }) => { - return new CoderClient({ + return new CoderClientWrapper({ apis: { urlSync, identityApi }, }); }, @@ -190,10 +193,13 @@ export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root' * General custom hooks that can be used in various places. */ export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; -export { useCoderSdk } from './hooks/useCoderSdk'; -export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; +export { useCoderApi } from './hooks/useCoderApi'; export { useCoderQuery } from './hooks/reactQueryWrappers'; +// Deliberately renamed so that end users don't have to be aware that there are +// two different versions of the auth hook +export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; + /** * General constants */ @@ -203,3 +209,4 @@ export { CODER_QUERY_KEY_PREFIX } from './api/queryOptions'; * All custom types */ export type { CoderAppConfig } from './components/CoderProvider'; +export type * from './api/vendoredSdk/api/typesGenerated'; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 8c96f8d2..843e4743 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -33,7 +33,10 @@ import { defaultUrlPrefixes, urlSyncApiRef, } from '../api/UrlSync'; -import { CoderClient, coderClientApiRef } from '../api/CoderClient'; +import { + CoderClientWrapper, + coderClientWrapperApiRef, +} from '../api/CoderClient'; /** * This is the key that Backstage checks from the entity data to determine the @@ -68,24 +71,24 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; /** * A version of the mock API endpoint that doesn't have the Coder API versioning - * prefix. Mainly used for tests that need to assert that the core API URL is - * formatted correctly, before the CoderSdk adds anything else to the end + * suffix. Mainly used for tests that need to assert that the core API URL is + * formatted correctly, before the Coder API adds anything else to the end * * The string literal expression is complicated, but hover over it to see what * the final result is. */ -export const mockBackstageApiEndpointWithoutSdkPath = +export const mockBackstageApiEndpointWithoutVersionSuffix = `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}` as const; /** * The API endpoint to use with the mock server during testing. Adds additional - * path information that will normally be added via the Coder SDK. + * path information that will normally be added via the Coder API. * * The string literal expression is complicated, but hover over it to see what * the final result is. */ export const mockBackstageApiEndpoint = - `${mockBackstageApiEndpointWithoutSdkPath}/api/v2` as const; + `${mockBackstageApiEndpointWithoutVersionSuffix}/api/v2` as const; /** * The assets endpoint to use during testing. @@ -173,7 +176,7 @@ const authedState = { error: undefined, isAuthenticated: true, registerNewToken: jest.fn(), - ejectToken: jest.fn(), + unlinkToken: jest.fn(), } as const satisfies Partial; const notAuthedState = { @@ -181,7 +184,7 @@ const notAuthedState = { error: undefined, isAuthenticated: false, registerNewToken: jest.fn(), - ejectToken: jest.fn(), + unlinkToken: jest.fn(), } as const satisfies Partial; export const mockAuthStates = { @@ -309,7 +312,7 @@ export function getMockApiList(): readonly ApiTuple[] { }, }); - const mockCoderClient = new CoderClient({ + const mockCoderClient = new CoderClientWrapper({ initialToken: mockCoderAuthToken, apis: { urlSync: mockUrlSyncApi, @@ -327,6 +330,6 @@ export function getMockApiList(): readonly ApiTuple[] { // Custom APIs specific to the Coder plugin [urlSyncApiRef, mockUrlSyncApi], - [coderClientApiRef, mockCoderClient], + [coderClientWrapperApiRef, mockCoderClient], ]; } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index cc8c67ad..b7d3191a 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -10,7 +10,11 @@ import { /* eslint-enable @backstage/no-undeclared-imports */ import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + type QueryClientConfig, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { type CoderAuth, @@ -93,13 +97,16 @@ export function suppressErrorBoundaryWarnings(): void { afterEachCleanupFunctions.push(() => augmentedConsoleError.mockClear()); } -export function getMockQueryClient(): QueryClient { +export function getMockQueryClient(config?: QueryClientConfig): QueryClient { return new QueryClient({ + ...(config ?? {}), defaultOptions: { + ...(config?.defaultOptions ?? {}), queries: { retry: false, refetchOnWindowFocus: false, networkMode: 'offlineFirst', + ...(config?.defaultOptions?.queries ?? {}), }, }, }); From 136c71d3a0b7dfca7e7156ae62b588df3186946d Mon Sep 17 00:00:00 2001 From: BioErrorLog <51422347+bioerrorlog@users.noreply.github.com> Date: Thu, 11 Jul 2024 07:47:23 +0900 Subject: [PATCH 30/33] docs: fix broken api reference link (#136) --- plugins/backstage-plugin-coder/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/README.md b/plugins/backstage-plugin-coder/README.md index 657521e2..ba7c6f02 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -142,7 +142,7 @@ spec: region: 'us-pittsburgh' ``` -You can find more information about what properties are available (and how they're applied) in our [`catalog-info.yaml` file documentation](./docs/catalog-info.md). +You can find more information about what properties are available (and how they're applied) in our [`catalog-info.yaml` file documentation](./docs/api-reference/catalog-info.md). ## Roadmap From ee842ec8ed7509dc0e29e6b967828c1e6b48dc17 Mon Sep 17 00:00:00 2001 From: BioErrorLog <51422347+bioerrorlog@users.noreply.github.com> Date: Thu, 11 Jul 2024 07:47:42 +0900 Subject: [PATCH 31/33] docs: fix incorrect descriptions in backstage-plugin-coder (#137) * docs: fix subtitle for catalog-info description * docs: fix incorrect reference --- plugins/backstage-plugin-coder/README.md | 2 +- .../backstage-plugin-coder/docs/api-reference/catalog-info.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/README.md b/plugins/backstage-plugin-coder/README.md index ba7c6f02..5ccc64a5 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -117,7 +117,7 @@ the Dev Container. ); ``` -### `app-config.yaml` files +### `catalog-info.yaml` files In addition to the above, you can define additional properties on your specific repo's `catalog-info.yaml` file. diff --git a/plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md b/plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md index 34fd72b3..cb3d9b56 100644 --- a/plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md +++ b/plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md @@ -42,7 +42,7 @@ This defines the name of the Coder template you would like to use when creating **Note:** This value has overlap with the `defaultTemplateName` property defined in [`CoderAppConfig`](types.md#coderappconfig). In the event that both values are present, the YAML file's `templateName` property will always be used instead. -### `templateName` +### `mode` **Type:** Optional union of `manual` or `auto` From c59f3ec8d12c0d3a3dabc5b83fee67bdfe54ece4 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 18 Jul 2024 09:41:05 -0400 Subject: [PATCH 32/33] add .coder.yaml file (#138) --- .coder.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .coder.yaml diff --git a/.coder.yaml b/.coder.yaml new file mode 100644 index 00000000..e334deda --- /dev/null +++ b/.coder.yaml @@ -0,0 +1,38 @@ +# .coder.yaml +# This is a Coder configuration file. It tells Coder how to create a workspace +# for this repository. You can use variables like {{org}}, {{repo}}, and {{ref}} +# to dynamically generate values. +# +# This configuration works well with Coder's git-clone module. To use it, you +# can add the following to your template: +# +# data "coder_parameter" "git_url" { +# type = "string" +# name = "Git URL" +# description = "The git repository URL to be cloned." +# default = "" +# mutable = true +# } +# +# module "git-clone" { +# source = "registry.coder.com/modules/git-clone/coder" +# version = "1.0.12" +# agent_id = +# url = data.coder_parameter.git_url.value +# } + +# Replace with your Coder deployment URL +host: dev.coder.com + +# Specify the Coder template for this repository +template: dogfood + +# Define a name for the new workspace using variables such as {{org}}, {{repo}}, +# and {{ref}} to dynamically generate values. This name is crucial as it is used +# to identify and potentially reuse an existing workspace within Coder. +name: {{repo}}-{{ref}} + +# Uncomment and use 'parameters' to override template defaults +# parameters: +# - name: "Git URL" +# value: "https://github.com/{{org}}/{{repo}}/tree/{{ref}}" From 9da52e6b2726c65205e6a23a6cd9821865cdad21 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 18 Jul 2024 09:46:49 -0400 Subject: [PATCH 33/33] fix: update base template name for .coder.yaml file (#139) --- .coder.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.coder.yaml b/.coder.yaml index e334deda..abdc589b 100644 --- a/.coder.yaml +++ b/.coder.yaml @@ -25,7 +25,7 @@ host: dev.coder.com # Specify the Coder template for this repository -template: dogfood +template: coder # Define a name for the new workspace using variables such as {{org}}, {{repo}}, # and {{ref}} to dynamically generate values. This name is crucial as it is used