diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 3c928430ed..307032e036 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -94,9 +94,25 @@ jobs: GH_TOKEN: $(LENS_IDE_GH_TOKEN) displayName: Customize config - - script: make build + - bash: | + set -e + + echo "Importing codesign certificate ..." + echo $CSC_LINK | base64 -D > certificate.p12 + security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security set-keychain-settings -lut 21600 build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security import certificate.p12 -k build.keychain -P $CSC_KEY_PASSWORD -T /usr/bin/codesign -T /usr/bin/security -A + security set-key-partition-list -S apple-tool:,apple: -k $KEYCHAIN_PASSWORD build.keychain + + rm certificate.p12 + echo "Codesign certificate imported!" + + make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: + KEYCHAIN_PASSWORD: secretz APPLEID: $(APPLEID) APPLEIDPASS: $(APPLEIDPASS) CSC_LINK: $(CSC_LINK) diff --git a/.eslintrc.js b/.eslintrc.js index 8cd6fd26fa..733f644615 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,15 +22,16 @@ module.exports = { { files: [ "**/*.js", + "**/*.mjs", ], extends: [ "eslint:recommended", ], env: { node: true, + es2022: true, }, parserOptions: { - ecmaVersion: 2018, sourceType: "module", }, plugins: [ @@ -129,6 +130,14 @@ module.exports = { "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-unused-vars": "off", + "no-restricted-imports": ["error", { + "paths": [ + { + "name": ".", + "message": "No importing from local index.ts(x?) file. A common way to make circular dependencies.", + }, + ], + }], "@typescript-eslint/member-delimiter-style": ["error", { "multiline": { "delimiter": "semi", @@ -139,6 +148,28 @@ module.exports = { "requireLast": false, }, }], + "react/jsx-max-props-per-line": ["error", { + "maximum": { + "single": 2, + "multi": 1, + }, + }], + "react/jsx-first-prop-new-line": ["error", "multiline"], + "react/jsx-one-expression-per-line": ["error", { + "allow": "single-child", + }], + "react/jsx-indent": ["error", 2], + "react/jsx-indent-props": ["error", 2], + "react/jsx-closing-tag-location": "error", + "react/jsx-wrap-multilines": ["error", { + "declaration": "parens-new-line", + "assignment": "parens-new-line", + "return": "parens-new-line", + "arrow": "parens-new-line", + "condition": "parens-new-line", + "logical": "parens-new-line", + "prop": "parens-new-line", + }], "react/display-name": "off", "space-before-function-paren": "off", "@typescript-eslint/space-before-function-paren": ["error", { @@ -217,5 +248,35 @@ module.exports = { "@typescript-eslint/consistent-type-imports": "error", }, }, + { + files: [ + "src/{common,main,renderer}/**/*.ts", + "src/{common,main,renderer}/**/*.tsx", + ], + rules: { + "no-restricted-imports": ["error", { + "paths": [ + { + "name": ".", + "message": "No importing from local index.ts(x?) file. A common way to make circular dependencies.", + }, + { + "name": "..", + "message": "No importing from parent index.ts(x?) file. A common way to make circular dependencies.", + }, + ], + "patterns": [ + { + "group": [ + "**/extensions/renderer-api/**/*", + "**/extensions/main-api/**/*", + "**/extensions/common-api/**/*", + ], + message: "No importing from the extension api definitions in application code", + }, + ], + }], + }, + }, ], }; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94beca0de6..44967d192c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,9 @@ jobs: - name: Install integration test dependencies id: minikube - uses: medyagh/setup-minikube@5a9a7104d7322fa40424de8855c84685e89cefd7 + uses: medyagh/setup-minikube@master + with: + minikube-version: latest if: runner.os == 'Linux' - run: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration diff --git a/.gitignore b/.gitignore index 4c9c3c8165..d018f3b251 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ locales/**/**.js lens.log static/build static/types -build/tray/ binaries/client/ binaries/server/ src/extensions/*/*.js diff --git a/Makefile b/Makefile index 1199ee381d..48ce768766 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ compile-dev: node_modules ci-validate-dev: binaries/client build-extensions compile-dev .PHONY: dev -dev: binaries/client build/tray/trayIconTemplate.png build-extensions +dev: binaries/client build-extensions rm -rf static/build/ yarn dev @@ -53,7 +53,7 @@ integration: build yarn integration .PHONY: build -build: node_modules binaries/client build/tray/trayIconTemplate.png +build: node_modules binaries/client yarn run npm:fix-build-version $(MAKE) build-extensions -B yarn run compile @@ -63,6 +63,10 @@ ifeq "$(DETECTED_OS)" "Windows" endif yarn run electron-builder --publish onTag $(ELECTRON_BUILDER_EXTRA_ARGS) +.PHONY: update-extension-locks +update-extension-locks: + $(foreach dir, $(extensions), (cd $(dir) && rm package-lock.json && ../../node_modules/.bin/npm install --package-lock-only);) + .NOTPARALLEL: $(extension_node_modules) $(extension_node_modules): node_modules cd $(@:/node_modules=) && ../../node_modules/.bin/npm install --no-audit --no-fund --no-save @@ -70,9 +74,6 @@ $(extension_node_modules): node_modules $(extension_dists): src/extensions/npm/extensions/dist $(extension_node_modules) cd $(@:/dist=) && ../../node_modules/.bin/npm run build -build/tray/trayIconTemplate.png: node_modules - yarn ts-node ./build/generate-tray-icons.ts - .PHONY: clean-old-extensions clean-old-extensions: find ./extensions -mindepth 1 -maxdepth 1 -type d '!' -exec test -e '{}/package.json' \; -exec rm -rf {} \; @@ -84,19 +85,17 @@ build-extensions: node_modules clean-old-extensions $(extension_dists) test-extensions: $(extension_node_modules) $(foreach dir, $(extensions), (cd $(dir) && npm run test || exit $?);) -.PHONY: copy-extension-themes -copy-extension-themes: - mkdir -p src/extensions/npm/extensions/dist/src/renderer/themes/ - cp $(wildcard src/renderer/themes/*.json) src/extensions/npm/extensions/dist/src/renderer/themes/ - src/extensions/npm/extensions/__mocks__: cp -r __mocks__ src/extensions/npm/extensions/ -src/extensions/npm/extensions/dist: node_modules +src/extensions/npm/extensions/dist: src/extensions/npm/extensions/node_modules yarn compile:extension-types +src/extensions/npm/extensions/node_modules: src/extensions/npm/extensions/package.json + cd src/extensions/npm/extensions/ && ../../../../node_modules/.bin/npm install --no-audit --no-fund + .PHONY: build-npm -build-npm: build-extension-types copy-extension-themes src/extensions/npm/extensions/__mocks__ +build-npm: build-extension-types src/extensions/npm/extensions/__mocks__ yarn npm:fix-package-version .PHONY: build-extension-types @@ -129,7 +128,6 @@ clean: clean-npm clean-extensions rm -rf binaries/client rm -rf dist rm -rf static/build - rm -rf build/tray rm -rf node_modules rm -rf site rm -rf docs/extensions/api diff --git a/README.md b/README.md index 595def597b..cb9f44b35d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Lens Open Source Project (OpenLens) [![Build Status](https://github.com/lensapp/lens/actions/workflows/test.yml/badge.svg)](https://github.com/lensapp/lens/actions/workflows/test.yml) -[![Chat on Slack](https://img.shields.io/badge/chat-on%20slack-blue.svg?logo=slack&longCache=true&style=flat)](https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI) +[![Chat on Slack](https://img.shields.io/badge/chat-on%20slack-blue.svg?logo=slack&longCache=true&style=flat)](https://join.slack.com/t/k8slens/shared_invite/zt-198iepl92-EPJsCckkJ~f887vWqJcgGA) ## The Repository diff --git a/__mocks__/@sentry/electron/main.ts b/__mocks__/@sentry/electron/main.ts new file mode 100644 index 0000000000..cbe02cb296 --- /dev/null +++ b/__mocks__/@sentry/electron/main.ts @@ -0,0 +1,5 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export default {}; diff --git a/__mocks__/@sentry/electron/renderer.ts b/__mocks__/@sentry/electron/renderer.ts new file mode 100644 index 0000000000..cbe02cb296 --- /dev/null +++ b/__mocks__/@sentry/electron/renderer.ts @@ -0,0 +1,5 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export default {}; diff --git a/build/build_theme_vars.ts b/build/build_theme_vars.ts index 44376712a7..dbade45847 100644 --- a/build/build_theme_vars.ts +++ b/build/build_theme_vars.ts @@ -5,11 +5,11 @@ import fs from "fs-extra"; import path from "path"; -import defaultBaseLensTheme from "../src/renderer/themes/lens-dark.json"; +import defaultBaseLensTheme from "../src/renderer/themes/lens-dark"; const outputCssFile = path.resolve("src/renderer/themes/theme-vars.css"); -const banner = `/* +const banner = `/* Generated Lens theme CSS-variables, don't edit manually. To refresh file run $: yarn run ts-node build/${path.basename(__filename)} */`; diff --git a/build/download_binaries.ts b/build/download_binaries.ts index 73c9dff175..5b4f960c5e 100644 --- a/build/download_binaries.ts +++ b/build/download_binaries.ts @@ -3,9 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import packageInfo from "../package.json"; -import { type WriteStream } from "fs"; import type { FileHandle } from "fs/promises"; import { open } from "fs/promises"; +import type { WriteStream } from "fs-extra"; import { constants, ensureDir, unlink } from "fs-extra"; import path from "path"; import fetch from "node-fetch"; @@ -17,6 +17,7 @@ import AbortController from "abort-controller"; import { extract } from "tar-stream"; import gunzip from "gunzip-maybe"; import { getBinaryName, normalizedPlatform } from "../src/common/vars"; +import { isErrnoException } from "../src/common/utils"; const pipeline = promisify(_pipeline); @@ -44,6 +45,10 @@ abstract class BinaryDownloader { } async ensureBinary(): Promise { + if (process.env.LENS_SKIP_DOWNLOAD_BINARIES === "true") { + return; + } + const controller = new AbortController(); const stream = await fetch(this.url, { timeout: 15 * 60 * 1000, // 15min @@ -51,7 +56,7 @@ abstract class BinaryDownloader { }); const total = Number(stream.headers.get("content-length")); const bar = this.bar; - let fileHandle: FileHandle; + let fileHandle: FileHandle | undefined = undefined; if (isNaN(total)) { throw new Error("no content-length header was present"); @@ -66,7 +71,7 @@ abstract class BinaryDownloader { * This is necessary because for some reason `createWriteStream({ flags: "wx" })` * was throwing someplace else and not here */ - fileHandle = await open(this.target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL); + const handle = fileHandle = await open(this.target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL); await pipeline( stream.body, @@ -79,7 +84,7 @@ abstract class BinaryDownloader { }), ...this.getTransformStreams(new Writable({ write(chunk, encoding, cb) { - fileHandle.write(chunk) + handle.write(chunk) .then(() => cb()) .catch(cb); }, @@ -90,7 +95,7 @@ abstract class BinaryDownloader { } catch (error) { await fileHandle?.close(); - if (error.code === "EEXIST") { + if (isErrnoException(error) && error.code === "EEXIST") { bar.increment(total); // mark as finished controller.abort(); // stop trying to download } else { diff --git a/build/generate-tray-icons.ts b/build/generate-tray-icons.ts index c09ab6320d..a7ab3bd48b 100644 --- a/build/generate-tray-icons.ts +++ b/build/generate-tray-icons.ts @@ -28,23 +28,23 @@ console.log("Generating tray icon pngs"); ensureDirSync(outputFolder); -Promise.allSettled([ - sharp(Buffer.from(darkTemplate)) +Promise.all([ + sharp(Buffer.from(lightTemplate)) .resize({ width: size, height: size }) .png() .toFile(path.join(outputFolder, "trayIconDarkTemplate.png")), - sharp(Buffer.from(darkTemplate)) + sharp(Buffer.from(lightTemplate)) .resize({ width: size*2, height: size*2 }) .png() .toFile(path.join(outputFolder, "trayIconDarkTemplate@2x.png")), - sharp(Buffer.from(lightTemplate)) + sharp(Buffer.from(darkTemplate)) .resize({ width: size, height: size }) .png() .toFile(path.join(outputFolder, "trayIconTemplate.png")), - sharp(Buffer.from(lightTemplate)) + sharp(Buffer.from(darkTemplate)) .resize({ width: size*2, height: size*2 }) .png() .toFile(path.join(outputFolder, "trayIconTemplate@2x.png")), ]) - .then(console.log) + .then((resolutions) => console.log(`Generated ${resolutions.length} images`)) .catch(console.error); diff --git a/build/tray/trayIconDarkTemplate.png b/build/tray/trayIconDarkTemplate.png new file mode 100644 index 0000000000..63f2eb1895 Binary files /dev/null and b/build/tray/trayIconDarkTemplate.png differ diff --git a/build/tray/trayIconDarkTemplate@2x.png b/build/tray/trayIconDarkTemplate@2x.png new file mode 100644 index 0000000000..c5dcfa9e15 Binary files /dev/null and b/build/tray/trayIconDarkTemplate@2x.png differ diff --git a/build/tray/trayIconTemplate.png b/build/tray/trayIconTemplate.png new file mode 100644 index 0000000000..0e1c5d6e8e Binary files /dev/null and b/build/tray/trayIconTemplate.png differ diff --git a/build/tray/trayIconTemplate@2x.png b/build/tray/trayIconTemplate@2x.png new file mode 100644 index 0000000000..553a8ec373 Binary files /dev/null and b/build/tray/trayIconTemplate@2x.png differ diff --git a/build/tsconfig.json b/build/tsconfig.json new file mode 100644 index 0000000000..7c6e822d2c --- /dev/null +++ b/build/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "./**/*", + ] +} diff --git a/docs/extensions/get-started/anatomy.md b/docs/extensions/get-started/anatomy.md index 481c18ac2c..8cfcd57076 100644 --- a/docs/extensions/get-started/anatomy.md +++ b/docs/extensions/get-started/anatomy.md @@ -79,7 +79,7 @@ Some of the most-important fields include: } ``` -## Webpack configuation +## Webpack configuration The following webpack `externals` are provided by `Lens` and must be used (when available) to make sure that the versions used are in sync. diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index 5d41dff89d..d90a343692 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -224,7 +224,7 @@ export default class ExampleExtension extends Renderer.LensExtension { { id: "bonjour", components: { - Page: () => , + Page: () => , }, }, ]; @@ -250,7 +250,7 @@ export default class ExampleExtension extends Renderer.LensExtension { target: { pageId: "bonjour" }, title: "Bonjour le monde", components: { - Icon: ExempleIcon, + Icon: ExampleIcon, }, }, ]; diff --git a/extensions/kube-object-event-status/package-lock.json b/extensions/kube-object-event-status/package-lock.json deleted file mode 100644 index 0a541e307a..0000000000 --- a/extensions/kube-object-event-status/package-lock.json +++ /dev/null @@ -1,2374 +0,0 @@ -{ - "name": "kube-object-event-status", - "version": "0.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@k8slens/extensions": { - "version": "file:../../src/extensions/npm/extensions", - "dev": true, - "requires": { - "@material-ui/core": "4.12.3", - "@types/node": "14.17.14", - "@types/react-select": "3.1.2", - "conf": "^7.0.1", - "typed-emitter": "^1.3.1" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.16.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz", - "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "dev": true - }, - "@material-ui/core": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", - "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/styles": "^4.11.4", - "@material-ui/system": "^4.12.1", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "@types/react-transition-group": "^4.2.0", - "clsx": "^1.0.4", - "hoist-non-react-statics": "^3.3.2", - "popper.js": "1.16.1-lts", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0", - "react-transition-group": "^4.4.0" - } - }, - "@material-ui/styles": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz", - "integrity": "sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@emotion/hash": "^0.8.0", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "clsx": "^1.0.4", - "csstype": "^2.5.2", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.5.1", - "jss-plugin-camel-case": "^10.5.1", - "jss-plugin-default-unit": "^10.5.1", - "jss-plugin-global": "^10.5.1", - "jss-plugin-nested": "^10.5.1", - "jss-plugin-props-sort": "^10.5.1", - "jss-plugin-rule-value-function": "^10.5.1", - "jss-plugin-vendor-prefixer": "^10.5.1", - "prop-types": "^15.7.2" - } - }, - "@material-ui/system": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.1.tgz", - "integrity": "sha512-lUdzs4q9kEXZGhbN7BptyiS1rLNHe6kG9o8Y307HCvF4sQxbCgpL2qi+gUk+yI8a2DNk48gISEQxoxpgph0xIw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.2", - "csstype": "^2.5.2", - "prop-types": "^15.7.2" - } - }, - "@material-ui/types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "dev": true - }, - "@material-ui/utils": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz", - "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" - } - }, - "@types/node": { - "version": "14.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", - "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==", - "dev": true - }, - "@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "dev": true - }, - "@types/react": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", - "integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", - "dev": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "@types/react-dom": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", - "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/react-select": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.1.2.tgz", - "integrity": "sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA==", - "dev": true, - "requires": { - "@types/react": "*", - "@types/react-dom": "*", - "@types/react-transition-group": "*" - } - }, - "@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "dev": true - }, - "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", - "dev": true - }, - "conf": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", - "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", - "dev": true, - "requires": { - "ajv": "^6.12.2", - "atomically": "^1.3.1", - "debounce-fn": "^4.0.0", - "dot-prop": "^5.2.0", - "env-paths": "^2.2.0", - "json-schema-typed": "^7.0.3", - "make-dir": "^3.1.0", - "onetime": "^5.1.0", - "pkg-up": "^3.1.0", - "semver": "^7.3.2" - } - }, - "css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", - "dev": true - }, - "debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dev": true, - "requires": { - "mimic-fn": "^3.0.0" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "requires": { - "is-obj": "^2.0.0" - } - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dev": true, - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", - "dev": true - }, - "is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=", - "dev": true - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", - "dev": true - }, - "jss": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.8.2.tgz", - "integrity": "sha512-FkoUNxI329CKQ9OQC8L72MBF9KPf5q8mIupAJ5twU7G7XREW7ahb+7jFfrjZ4iy1qvhx1HwIWUIvkZBDnKkEdQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "jss-plugin-camel-case": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.8.2.tgz", - "integrity": "sha512-2INyxR+1UdNuKf4v9It3tNfPvf7IPrtkiwzofeKuMd5D58/dxDJVUQYRVg/n460rTlHUfsEQx43hDrcxi9dSPA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.8.2" - } - }, - "jss-plugin-default-unit": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.8.2.tgz", - "integrity": "sha512-UZ7cwT9NFYSG+SEy7noRU50s4zifulFdjkUNKE+u6mW7vFP960+RglWjTgMfh79G6OENZmaYnjHV/gcKV4nSxg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-global": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.8.2.tgz", - "integrity": "sha512-UaYMSPsYZ7s/ECGoj4KoHC2jwQd5iQ7K+FFGnCAILdQrv7hPmvM2Ydg45ThT/sH46DqktCRV2SqjRuxeBH8nRA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-nested": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.8.2.tgz", - "integrity": "sha512-acRvuPJOb930fuYmhkJaa994EADpt8TxI63Iyg96C8FJ9T2xRyU5T6R1IYKRwUiqZo+2Sr7fdGzRTDD4uBZaMA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-props-sort": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.8.2.tgz", - "integrity": "sha512-wqdcjayKRWBZnNpLUrXvsWqh+5J5YToAQ+8HNBNw0kZxVvCDwzhK2Nx6AKs7p+5/MbAh2PLgNW5Ym/ysbVAuqQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-rule-value-function": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.8.2.tgz", - "integrity": "sha512-bW0EKAs+0HXpb6BKJhrn94IDdiWb0CnSluTkh0rGEgyzY/nmD1uV/Wf6KGlesGOZ9gmJzQy+9FFdxIUID1c9Ug==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-vendor-prefixer": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.8.2.tgz", - "integrity": "sha512-DeGv18QsSiYLSVIEB2+l0af6OToUe0JB+trpzUxyqD2QRC/5AzzDrCrYffO5AHZ81QbffYvSN/pkfZaTWpRXlg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.8.2" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - }, - "dependencies": { - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - } - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, - "pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "requires": { - "find-up": "^3.0.0" - } - }, - "popper.js": { - "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", - "dev": true - }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - } - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "dev": true - }, - "typed-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.3.1.tgz", - "integrity": "sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ==", - "dev": true - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "npm": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/npm/-/npm-8.5.3.tgz", - "integrity": "sha512-O+1j66Alx7ZQgWnUSSTaz8rTqQrJnqNb8Num5uQw2vYvc2RrxLaX7cWtRkDhvkPIL8Nf2WU9gx1oSu268QConA==", - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/config": "^4.0.1", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.1", - "abbrev": "~1.1.1", - "ansicolors": "~0.3.2", - "ansistyles": "~0.1.3", - "archy": "~1.0.0", - "cacache": "^15.3.0", - "chalk": "^4.1.2", - "chownr": "^2.0.0", - "cli-columns": "^4.0.0", - "cli-table3": "^0.6.1", - "columnify": "^1.6.0", - "fastest-levenshtein": "^1.0.12", - "glob": "^7.2.0", - "graceful-fs": "^4.2.9", - "hosted-git-info": "^4.1.0", - "ini": "^2.0.0", - "init-package-json": "^3.0.0", - "is-cidr": "^4.0.2", - "json-parse-even-better-errors": "^2.3.1", - "libnpmaccess": "^6.0.0", - "libnpmdiff": "^4.0.0", - "libnpmexec": "^4.0.0", - "libnpmfund": "^3.0.0", - "libnpmhook": "^8.0.0", - "libnpmorg": "^4.0.0", - "libnpmpack": "^4.0.0", - "libnpmpublish": "^6.0.0", - "libnpmsearch": "^5.0.0", - "libnpmteam": "^4.0.0", - "libnpmversion": "^3.0.0", - "make-fetch-happen": "^10.0.4", - "minipass": "^3.1.6", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "ms": "^2.1.2", - "node-gyp": "^9.0.0", - "nopt": "^5.0.0", - "npm-audit-report": "^2.1.5", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-profile": "^6.0.2", - "npm-registry-fetch": "^13.0.1", - "npm-user-validate": "^1.0.1", - "npmlog": "^6.0.1", - "opener": "^1.5.2", - "pacote": "^13.0.3", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "qrcode-terminal": "^0.12.0", - "read": "~1.0.7", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "tar": "^6.1.11", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^1.0.4", - "validate-npm-package-name": "~3.0.0", - "which": "^2.0.2", - "write-file-atomic": "^4.0.1" - }, - "dependencies": { - "@gar/promisify": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "@isaacs/string-locale-compare": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "@npmcli/arborist": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/metavuln-calculator": "^3.0.0", - "@npmcli/move-file": "^1.1.0", - "@npmcli/name-from-folder": "^1.0.1", - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.0", - "bin-links": "^3.0.0", - "cacache": "^15.0.3", - "common-ancestor-path": "^1.0.1", - "json-parse-even-better-errors": "^2.3.1", - "json-stringify-nice": "^1.1.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^1.0.1", - "read-package-json-fast": "^2.0.2", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "treeverse": "^1.0.4", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/ci-detect": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "@npmcli/config": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/map-workspaces": "^2.0.1", - "ini": "^2.0.0", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "proc-log": "^2.0.0", - "read-package-json-fast": "^2.0.3", - "semver": "^7.3.5", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/disparity-colors": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.3.0" - } - }, - "@npmcli/fs": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/git": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/promise-spawn": "^1.3.2", - "lru-cache": "^7.3.1", - "mkdirp": "^1.0.4", - "npm-pick-manifest": "^7.0.0", - "proc-log": "^2.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^2.0.2" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "@npmcli/installed-package-contents": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "@npmcli/map-workspaces": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/name-from-folder": "^1.0.1", - "glob": "^7.2.0", - "minimatch": "^5.0.0", - "read-package-json-fast": "^2.0.3" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "5.0.1", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@npmcli/metavuln-calculator": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cacache": "^15.3.0", - "json-parse-even-better-errors": "^2.3.1", - "pacote": "^13.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/move-file": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - } - }, - "@npmcli/name-from-folder": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "@npmcli/node-gyp": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "@npmcli/package-json": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1" - } - }, - "@npmcli/promise-spawn": { - "version": "1.3.2", - "bundled": true, - "dev": true, - "requires": { - "infer-owner": "^1.0.4" - } - }, - "@npmcli/run-script": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/promise-spawn": "^1.3.2", - "node-gyp": "^9.0.0", - "read-package-json-fast": "^2.0.3" - } - }, - "@tootallnate/once": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "abbrev": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "agent-base": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "debug": "4" - } - }, - "agentkeepalive": { - "version": "4.2.1", - "bundled": true, - "dev": true, - "requires": { - "debug": "^4.1.0", - "depd": "^1.1.2", - "humanize-ms": "^1.2.1" - } - }, - "aggregate-error": { - "version": "3.1.0", - "bundled": true, - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, - "ansi-styles": { - "version": "4.3.0", - "bundled": true, - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "ansicolors": { - "version": "0.3.2", - "bundled": true, - "dev": true - }, - "ansistyles": { - "version": "0.1.3", - "bundled": true, - "dev": true - }, - "aproba": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "archy": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "are-we-there-yet": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "asap": { - "version": "2.0.6", - "bundled": true, - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "bin-links": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cmd-shim": "^4.0.1", - "mkdirp-infer-owner": "^2.0.0", - "npm-normalize-package-bin": "^1.0.0", - "read-cmd-shim": "^2.0.0", - "rimraf": "^3.0.0", - "write-file-atomic": "^4.0.0" - } - }, - "binary-extensions": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "builtins": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "cacache": { - "version": "15.3.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - } - }, - "chalk": { - "version": "4.1.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chownr": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "cidr-regex": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "ip-regex": "^4.1.0" - } - }, - "clean-stack": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "cli-columns": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "cli-table3": { - "version": "0.6.1", - "bundled": true, - "dev": true, - "requires": { - "colors": "1.4.0", - "string-width": "^4.2.0" - } - }, - "clone": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "cmd-shim": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "mkdirp-infer-owner": "^2.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "color-support": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "colors": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "optional": true - }, - "columnify": { - "version": "1.6.0", - "bundled": true, - "dev": true, - "requires": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "common-ancestor-path": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true, - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "debug": { - "version": "4.3.3", - "bundled": true, - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "bundled": true, - "dev": true - } - } - }, - "debuglog": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "defaults": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "clone": "^1.0.2" - } - }, - "delegates": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "depd": { - "version": "1.1.2", - "bundled": true, - "dev": true - }, - "dezalgo": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "diff": { - "version": "5.0.0", - "bundled": true, - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "bundled": true, - "dev": true - }, - "encoding": { - "version": "0.1.13", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - } - }, - "env-paths": { - "version": "2.2.1", - "bundled": true, - "dev": true - }, - "err-code": { - "version": "2.0.3", - "bundled": true, - "dev": true - }, - "fastest-levenshtein": { - "version": "1.0.12", - "bundled": true, - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "function-bind": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "gauge": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1", - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "glob": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "graceful-fs": { - "version": "4.2.9", - "bundled": true, - "dev": true - }, - "has": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "has-unicode": { - "version": "2.0.1", - "bundled": true, - "dev": true - }, - "hosted-git-info": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "http-cache-semantics": { - "version": "4.1.0", - "bundled": true, - "dev": true - }, - "http-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "humanize-ms": { - "version": "1.2.1", - "bundled": true, - "dev": true, - "requires": { - "ms": "^2.0.0" - } - }, - "iconv-lite": { - "version": "0.6.3", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ignore-walk": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "imurmurhash": { - "version": "0.1.4", - "bundled": true, - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "infer-owner": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "bundled": true, - "dev": true - }, - "ini": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "init-package-json": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-package-arg": "^9.0.0", - "promzard": "^0.3.0", - "read": "^1.0.7", - "read-package-json": "^4.1.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^3.0.0" - } - }, - "ip": { - "version": "1.1.5", - "bundled": true, - "dev": true - }, - "ip-regex": { - "version": "4.3.0", - "bundled": true, - "dev": true - }, - "is-cidr": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "cidr-regex": "^3.1.1" - } - }, - "is-core-module": { - "version": "2.8.1", - "bundled": true, - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "bundled": true, - "dev": true - }, - "is-lambda": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "isexe": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "bundled": true, - "dev": true - }, - "json-stringify-nice": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "jsonparse": { - "version": "1.3.1", - "bundled": true, - "dev": true - }, - "just-diff": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "just-diff-apply": { - "version": "4.0.1", - "bundled": true, - "dev": true - }, - "libnpmaccess": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "minipass": "^3.1.1", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmdiff": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/disparity-colors": "^1.0.1", - "@npmcli/installed-package-contents": "^1.0.7", - "binary-extensions": "^2.2.0", - "diff": "^5.0.0", - "minimatch": "^3.0.4", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2", - "tar": "^6.1.0" - } - }, - "libnpmexec": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/run-script": "^3.0.0", - "chalk": "^4.1.0", - "mkdirp-infer-owner": "^2.0.0", - "npm-package-arg": "^9.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "proc-log": "^2.0.0", - "read": "^1.0.7", - "read-package-json-fast": "^2.0.2", - "walk-up-path": "^1.0.0" - } - }, - "libnpmfund": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0" - } - }, - "libnpmhook": { - "version": "8.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmorg": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmpack": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/run-script": "^3.0.0", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2" - } - }, - "libnpmpublish": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "normalize-package-data": "^3.0.2", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0", - "semver": "^7.1.3", - "ssri": "^8.0.1" - } - }, - "libnpmsearch": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmteam": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmversion": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/run-script": "^3.0.0", - "json-parse-even-better-errors": "^2.3.1", - "proc-log": "^2.0.0", - "semver": "^7.3.5", - "stringify-package": "^1.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-fetch-happen": { - "version": "10.0.4", - "bundled": true, - "dev": true, - "requires": { - "agentkeepalive": "^4.2.1", - "cacache": "^15.3.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.4.0", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.1.1", - "ssri": "^8.0.1" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "minimatch": { - "version": "3.1.2", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minipass": { - "version": "3.1.6", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minipass-collect": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-fetch": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "encoding": "^0.1.13", - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - } - }, - "minipass-flush": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-json-stream": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-sized": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mkdirp": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "mkdirp-infer-owner": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "infer-owner": "^1.0.4", - "mkdirp": "^1.0.3" - } - }, - "ms": { - "version": "2.1.3", - "bundled": true, - "dev": true - }, - "mute-stream": { - "version": "0.0.8", - "bundled": true, - "dev": true - }, - "negotiator": { - "version": "0.6.3", - "bundled": true, - "dev": true - }, - "node-gyp": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - } - }, - "nopt": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "3.0.3", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - } - }, - "npm-audit-report": { - "version": "2.1.5", - "bundled": true, - "dev": true, - "requires": { - "chalk": "^4.0.0" - } - }, - "npm-bundled": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-install-checks": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "semver": "^7.1.1" - } - }, - "npm-normalize-package-bin": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npm-package-arg": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.1.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^3.0.0" - } - }, - "npm-packlist": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.6", - "ignore-walk": "^4.0.1", - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-pick-manifest": { - "version": "7.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-install-checks": "^4.0.0", - "npm-normalize-package-bin": "^1.0.1", - "npm-package-arg": "^9.0.0", - "semver": "^7.3.5" - } - }, - "npm-profile": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-registry-fetch": { - "version": "13.0.1", - "bundled": true, - "dev": true, - "requires": { - "make-fetch-happen": "^10.0.3", - "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-user-validate": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npmlog": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.0", - "set-blocking": "^2.0.0" - } - }, - "once": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "opener": { - "version": "1.5.2", - "bundled": true, - "dev": true - }, - "p-map": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "pacote": { - "version": "13.0.3", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/promise-spawn": "^1.2.0", - "@npmcli/run-script": "^3.0.0", - "cacache": "^15.3.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "infer-owner": "^1.0.4", - "minipass": "^3.1.6", - "mkdirp": "^1.0.4", - "npm-package-arg": "^9.0.0", - "npm-packlist": "^3.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0", - "promise-retry": "^2.0.1", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.1.11" - } - }, - "parse-conflict-json": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1", - "just-diff": "^5.0.1", - "just-diff-apply": "^4.0.1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "proc-log": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "promise-all-reject-late": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-call-limit": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-inflight": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-retry": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "promzard": { - "version": "0.3.0", - "bundled": true, - "dev": true, - "requires": { - "read": "1" - } - }, - "qrcode-terminal": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "read": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "mute-stream": "~0.0.4" - } - }, - "read-cmd-shim": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "read-package-json": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.1", - "json-parse-even-better-errors": "^2.3.0", - "normalize-package-data": "^3.0.0", - "npm-normalize-package-bin": "^1.0.0" - } - }, - "read-package-json-fast": { - "version": "2.0.3", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.0", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "readable-stream": { - "version": "3.6.0", - "bundled": true, - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "readdir-scoped-modules": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "debuglog": "^1.0.1", - "dezalgo": "^1.0.0", - "graceful-fs": "^4.1.2", - "once": "^1.3.0" - } - }, - "retry": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.2.1", - "bundled": true, - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "semver": { - "version": "7.3.5", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "signal-exit": { - "version": "3.0.7", - "bundled": true, - "dev": true - }, - "smart-buffer": { - "version": "4.2.0", - "bundled": true, - "dev": true - }, - "socks": { - "version": "2.6.2", - "bundled": true, - "dev": true, - "requires": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "6.1.1", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "^6.0.2", - "debug": "^4.3.1", - "socks": "^2.6.1" - } - }, - "spdx-correct": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "bundled": true, - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.11", - "bundled": true, - "dev": true - }, - "ssri": { - "version": "8.0.1", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.1.1" - } - }, - "string-width": { - "version": "4.2.3", - "bundled": true, - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "string_decoder": { - "version": "1.3.0", - "bundled": true, - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "stringify-package": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tar": { - "version": "6.1.11", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "text-table": { - "version": "0.2.0", - "bundled": true, - "dev": true - }, - "tiny-relative-date": { - "version": "1.3.0", - "bundled": true, - "dev": true - }, - "treeverse": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "unique-filename": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "requires": { - "unique-slug": "^2.0.0" - } - }, - "unique-slug": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "bundled": true, - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validate-npm-package-name": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "builtins": "^1.0.3" - } - }, - "walk-up-path": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "wcwidth": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "defaults": "^1.0.3" - } - }, - "which": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wide-align": { - "version": "1.1.5", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "write-file-atomic": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - }, - "yallist": { - "version": "4.0.0", - "bundled": true, - "dev": true - } - } - } - } -} diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json deleted file mode 100644 index 7025687b2b..0000000000 --- a/extensions/metrics-cluster-feature/package-lock.json +++ /dev/null @@ -1,2380 +0,0 @@ -{ - "name": "lens-metrics-cluster-feature", - "version": "0.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@k8slens/extensions": { - "version": "file:../../src/extensions/npm/extensions", - "dev": true, - "requires": { - "@material-ui/core": "4.12.3", - "@types/node": "14.17.14", - "@types/react-select": "3.1.2", - "conf": "^7.0.1", - "typed-emitter": "^1.3.1" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.16.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz", - "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "dev": true - }, - "@material-ui/core": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", - "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/styles": "^4.11.4", - "@material-ui/system": "^4.12.1", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "@types/react-transition-group": "^4.2.0", - "clsx": "^1.0.4", - "hoist-non-react-statics": "^3.3.2", - "popper.js": "1.16.1-lts", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0", - "react-transition-group": "^4.4.0" - } - }, - "@material-ui/styles": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz", - "integrity": "sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@emotion/hash": "^0.8.0", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "clsx": "^1.0.4", - "csstype": "^2.5.2", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.5.1", - "jss-plugin-camel-case": "^10.5.1", - "jss-plugin-default-unit": "^10.5.1", - "jss-plugin-global": "^10.5.1", - "jss-plugin-nested": "^10.5.1", - "jss-plugin-props-sort": "^10.5.1", - "jss-plugin-rule-value-function": "^10.5.1", - "jss-plugin-vendor-prefixer": "^10.5.1", - "prop-types": "^15.7.2" - } - }, - "@material-ui/system": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.1.tgz", - "integrity": "sha512-lUdzs4q9kEXZGhbN7BptyiS1rLNHe6kG9o8Y307HCvF4sQxbCgpL2qi+gUk+yI8a2DNk48gISEQxoxpgph0xIw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.2", - "csstype": "^2.5.2", - "prop-types": "^15.7.2" - } - }, - "@material-ui/types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "dev": true - }, - "@material-ui/utils": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz", - "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" - } - }, - "@types/node": { - "version": "14.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", - "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==", - "dev": true - }, - "@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "dev": true - }, - "@types/react": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", - "integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", - "dev": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "@types/react-dom": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", - "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/react-select": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.1.2.tgz", - "integrity": "sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA==", - "dev": true, - "requires": { - "@types/react": "*", - "@types/react-dom": "*", - "@types/react-transition-group": "*" - } - }, - "@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "dev": true - }, - "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", - "dev": true - }, - "conf": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", - "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", - "dev": true, - "requires": { - "ajv": "^6.12.2", - "atomically": "^1.3.1", - "debounce-fn": "^4.0.0", - "dot-prop": "^5.2.0", - "env-paths": "^2.2.0", - "json-schema-typed": "^7.0.3", - "make-dir": "^3.1.0", - "onetime": "^5.1.0", - "pkg-up": "^3.1.0", - "semver": "^7.3.2" - } - }, - "css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", - "dev": true - }, - "debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dev": true, - "requires": { - "mimic-fn": "^3.0.0" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "requires": { - "is-obj": "^2.0.0" - } - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dev": true, - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", - "dev": true - }, - "is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=", - "dev": true - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", - "dev": true - }, - "jss": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.8.2.tgz", - "integrity": "sha512-FkoUNxI329CKQ9OQC8L72MBF9KPf5q8mIupAJ5twU7G7XREW7ahb+7jFfrjZ4iy1qvhx1HwIWUIvkZBDnKkEdQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "jss-plugin-camel-case": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.8.2.tgz", - "integrity": "sha512-2INyxR+1UdNuKf4v9It3tNfPvf7IPrtkiwzofeKuMd5D58/dxDJVUQYRVg/n460rTlHUfsEQx43hDrcxi9dSPA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.8.2" - } - }, - "jss-plugin-default-unit": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.8.2.tgz", - "integrity": "sha512-UZ7cwT9NFYSG+SEy7noRU50s4zifulFdjkUNKE+u6mW7vFP960+RglWjTgMfh79G6OENZmaYnjHV/gcKV4nSxg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-global": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.8.2.tgz", - "integrity": "sha512-UaYMSPsYZ7s/ECGoj4KoHC2jwQd5iQ7K+FFGnCAILdQrv7hPmvM2Ydg45ThT/sH46DqktCRV2SqjRuxeBH8nRA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-nested": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.8.2.tgz", - "integrity": "sha512-acRvuPJOb930fuYmhkJaa994EADpt8TxI63Iyg96C8FJ9T2xRyU5T6R1IYKRwUiqZo+2Sr7fdGzRTDD4uBZaMA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-props-sort": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.8.2.tgz", - "integrity": "sha512-wqdcjayKRWBZnNpLUrXvsWqh+5J5YToAQ+8HNBNw0kZxVvCDwzhK2Nx6AKs7p+5/MbAh2PLgNW5Ym/ysbVAuqQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-rule-value-function": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.8.2.tgz", - "integrity": "sha512-bW0EKAs+0HXpb6BKJhrn94IDdiWb0CnSluTkh0rGEgyzY/nmD1uV/Wf6KGlesGOZ9gmJzQy+9FFdxIUID1c9Ug==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-vendor-prefixer": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.8.2.tgz", - "integrity": "sha512-DeGv18QsSiYLSVIEB2+l0af6OToUe0JB+trpzUxyqD2QRC/5AzzDrCrYffO5AHZ81QbffYvSN/pkfZaTWpRXlg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.8.2" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - }, - "dependencies": { - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - } - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, - "pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "requires": { - "find-up": "^3.0.0" - } - }, - "popper.js": { - "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", - "dev": true - }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - } - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "dev": true - }, - "typed-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.3.1.tgz", - "integrity": "sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ==", - "dev": true - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "npm": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/npm/-/npm-8.5.3.tgz", - "integrity": "sha512-O+1j66Alx7ZQgWnUSSTaz8rTqQrJnqNb8Num5uQw2vYvc2RrxLaX7cWtRkDhvkPIL8Nf2WU9gx1oSu268QConA==", - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/config": "^4.0.1", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.1", - "abbrev": "~1.1.1", - "ansicolors": "~0.3.2", - "ansistyles": "~0.1.3", - "archy": "~1.0.0", - "cacache": "^15.3.0", - "chalk": "^4.1.2", - "chownr": "^2.0.0", - "cli-columns": "^4.0.0", - "cli-table3": "^0.6.1", - "columnify": "^1.6.0", - "fastest-levenshtein": "^1.0.12", - "glob": "^7.2.0", - "graceful-fs": "^4.2.9", - "hosted-git-info": "^4.1.0", - "ini": "^2.0.0", - "init-package-json": "^3.0.0", - "is-cidr": "^4.0.2", - "json-parse-even-better-errors": "^2.3.1", - "libnpmaccess": "^6.0.0", - "libnpmdiff": "^4.0.0", - "libnpmexec": "^4.0.0", - "libnpmfund": "^3.0.0", - "libnpmhook": "^8.0.0", - "libnpmorg": "^4.0.0", - "libnpmpack": "^4.0.0", - "libnpmpublish": "^6.0.0", - "libnpmsearch": "^5.0.0", - "libnpmteam": "^4.0.0", - "libnpmversion": "^3.0.0", - "make-fetch-happen": "^10.0.4", - "minipass": "^3.1.6", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "ms": "^2.1.2", - "node-gyp": "^9.0.0", - "nopt": "^5.0.0", - "npm-audit-report": "^2.1.5", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-profile": "^6.0.2", - "npm-registry-fetch": "^13.0.1", - "npm-user-validate": "^1.0.1", - "npmlog": "^6.0.1", - "opener": "^1.5.2", - "pacote": "^13.0.3", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "qrcode-terminal": "^0.12.0", - "read": "~1.0.7", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "tar": "^6.1.11", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^1.0.4", - "validate-npm-package-name": "~3.0.0", - "which": "^2.0.2", - "write-file-atomic": "^4.0.1" - }, - "dependencies": { - "@gar/promisify": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "@isaacs/string-locale-compare": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "@npmcli/arborist": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/metavuln-calculator": "^3.0.0", - "@npmcli/move-file": "^1.1.0", - "@npmcli/name-from-folder": "^1.0.1", - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.0", - "bin-links": "^3.0.0", - "cacache": "^15.0.3", - "common-ancestor-path": "^1.0.1", - "json-parse-even-better-errors": "^2.3.1", - "json-stringify-nice": "^1.1.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^1.0.1", - "read-package-json-fast": "^2.0.2", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "treeverse": "^1.0.4", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/ci-detect": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "@npmcli/config": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/map-workspaces": "^2.0.1", - "ini": "^2.0.0", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "proc-log": "^2.0.0", - "read-package-json-fast": "^2.0.3", - "semver": "^7.3.5", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/disparity-colors": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.3.0" - } - }, - "@npmcli/fs": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/git": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/promise-spawn": "^1.3.2", - "lru-cache": "^7.3.1", - "mkdirp": "^1.0.4", - "npm-pick-manifest": "^7.0.0", - "proc-log": "^2.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^2.0.2" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "@npmcli/installed-package-contents": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "@npmcli/map-workspaces": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/name-from-folder": "^1.0.1", - "glob": "^7.2.0", - "minimatch": "^5.0.0", - "read-package-json-fast": "^2.0.3" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "5.0.1", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@npmcli/metavuln-calculator": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cacache": "^15.3.0", - "json-parse-even-better-errors": "^2.3.1", - "pacote": "^13.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/move-file": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - } - }, - "@npmcli/name-from-folder": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "@npmcli/node-gyp": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "@npmcli/package-json": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1" - } - }, - "@npmcli/promise-spawn": { - "version": "1.3.2", - "bundled": true, - "dev": true, - "requires": { - "infer-owner": "^1.0.4" - } - }, - "@npmcli/run-script": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/promise-spawn": "^1.3.2", - "node-gyp": "^9.0.0", - "read-package-json-fast": "^2.0.3" - } - }, - "@tootallnate/once": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "abbrev": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "agent-base": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "debug": "4" - } - }, - "agentkeepalive": { - "version": "4.2.1", - "bundled": true, - "dev": true, - "requires": { - "debug": "^4.1.0", - "depd": "^1.1.2", - "humanize-ms": "^1.2.1" - } - }, - "aggregate-error": { - "version": "3.1.0", - "bundled": true, - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, - "ansi-styles": { - "version": "4.3.0", - "bundled": true, - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "ansicolors": { - "version": "0.3.2", - "bundled": true, - "dev": true - }, - "ansistyles": { - "version": "0.1.3", - "bundled": true, - "dev": true - }, - "aproba": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "archy": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "are-we-there-yet": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "asap": { - "version": "2.0.6", - "bundled": true, - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "bin-links": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cmd-shim": "^4.0.1", - "mkdirp-infer-owner": "^2.0.0", - "npm-normalize-package-bin": "^1.0.0", - "read-cmd-shim": "^2.0.0", - "rimraf": "^3.0.0", - "write-file-atomic": "^4.0.0" - } - }, - "binary-extensions": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "builtins": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "cacache": { - "version": "15.3.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - } - }, - "chalk": { - "version": "4.1.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chownr": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "cidr-regex": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "ip-regex": "^4.1.0" - } - }, - "clean-stack": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "cli-columns": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "cli-table3": { - "version": "0.6.1", - "bundled": true, - "dev": true, - "requires": { - "colors": "1.4.0", - "string-width": "^4.2.0" - } - }, - "clone": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "cmd-shim": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "mkdirp-infer-owner": "^2.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "color-support": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "colors": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "optional": true - }, - "columnify": { - "version": "1.6.0", - "bundled": true, - "dev": true, - "requires": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "common-ancestor-path": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true, - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "debug": { - "version": "4.3.3", - "bundled": true, - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "bundled": true, - "dev": true - } - } - }, - "debuglog": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "defaults": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "clone": "^1.0.2" - } - }, - "delegates": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "depd": { - "version": "1.1.2", - "bundled": true, - "dev": true - }, - "dezalgo": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "diff": { - "version": "5.0.0", - "bundled": true, - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "bundled": true, - "dev": true - }, - "encoding": { - "version": "0.1.13", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - } - }, - "env-paths": { - "version": "2.2.1", - "bundled": true, - "dev": true - }, - "err-code": { - "version": "2.0.3", - "bundled": true, - "dev": true - }, - "fastest-levenshtein": { - "version": "1.0.12", - "bundled": true, - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "function-bind": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "gauge": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1", - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "glob": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "graceful-fs": { - "version": "4.2.9", - "bundled": true, - "dev": true - }, - "has": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "has-unicode": { - "version": "2.0.1", - "bundled": true, - "dev": true - }, - "hosted-git-info": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "http-cache-semantics": { - "version": "4.1.0", - "bundled": true, - "dev": true - }, - "http-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "humanize-ms": { - "version": "1.2.1", - "bundled": true, - "dev": true, - "requires": { - "ms": "^2.0.0" - } - }, - "iconv-lite": { - "version": "0.6.3", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ignore-walk": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "imurmurhash": { - "version": "0.1.4", - "bundled": true, - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "infer-owner": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "bundled": true, - "dev": true - }, - "ini": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "init-package-json": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-package-arg": "^9.0.0", - "promzard": "^0.3.0", - "read": "^1.0.7", - "read-package-json": "^4.1.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^3.0.0" - } - }, - "ip": { - "version": "1.1.5", - "bundled": true, - "dev": true - }, - "ip-regex": { - "version": "4.3.0", - "bundled": true, - "dev": true - }, - "is-cidr": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "cidr-regex": "^3.1.1" - } - }, - "is-core-module": { - "version": "2.8.1", - "bundled": true, - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "bundled": true, - "dev": true - }, - "is-lambda": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "isexe": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "bundled": true, - "dev": true - }, - "json-stringify-nice": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "jsonparse": { - "version": "1.3.1", - "bundled": true, - "dev": true - }, - "just-diff": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "just-diff-apply": { - "version": "4.0.1", - "bundled": true, - "dev": true - }, - "libnpmaccess": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "minipass": "^3.1.1", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmdiff": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/disparity-colors": "^1.0.1", - "@npmcli/installed-package-contents": "^1.0.7", - "binary-extensions": "^2.2.0", - "diff": "^5.0.0", - "minimatch": "^3.0.4", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2", - "tar": "^6.1.0" - } - }, - "libnpmexec": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/run-script": "^3.0.0", - "chalk": "^4.1.0", - "mkdirp-infer-owner": "^2.0.0", - "npm-package-arg": "^9.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "proc-log": "^2.0.0", - "read": "^1.0.7", - "read-package-json-fast": "^2.0.2", - "walk-up-path": "^1.0.0" - } - }, - "libnpmfund": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0" - } - }, - "libnpmhook": { - "version": "8.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmorg": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmpack": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/run-script": "^3.0.0", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2" - } - }, - "libnpmpublish": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "normalize-package-data": "^3.0.2", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0", - "semver": "^7.1.3", - "ssri": "^8.0.1" - } - }, - "libnpmsearch": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmteam": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmversion": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/run-script": "^3.0.0", - "json-parse-even-better-errors": "^2.3.1", - "proc-log": "^2.0.0", - "semver": "^7.3.5", - "stringify-package": "^1.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-fetch-happen": { - "version": "10.0.4", - "bundled": true, - "dev": true, - "requires": { - "agentkeepalive": "^4.2.1", - "cacache": "^15.3.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.4.0", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.1.1", - "ssri": "^8.0.1" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "minimatch": { - "version": "3.1.2", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minipass": { - "version": "3.1.6", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minipass-collect": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-fetch": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "encoding": "^0.1.13", - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - } - }, - "minipass-flush": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-json-stream": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-sized": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mkdirp": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "mkdirp-infer-owner": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "infer-owner": "^1.0.4", - "mkdirp": "^1.0.3" - } - }, - "ms": { - "version": "2.1.3", - "bundled": true, - "dev": true - }, - "mute-stream": { - "version": "0.0.8", - "bundled": true, - "dev": true - }, - "negotiator": { - "version": "0.6.3", - "bundled": true, - "dev": true - }, - "node-gyp": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - } - }, - "nopt": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "3.0.3", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - } - }, - "npm-audit-report": { - "version": "2.1.5", - "bundled": true, - "dev": true, - "requires": { - "chalk": "^4.0.0" - } - }, - "npm-bundled": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-install-checks": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "semver": "^7.1.1" - } - }, - "npm-normalize-package-bin": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npm-package-arg": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.1.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^3.0.0" - } - }, - "npm-packlist": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.6", - "ignore-walk": "^4.0.1", - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-pick-manifest": { - "version": "7.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-install-checks": "^4.0.0", - "npm-normalize-package-bin": "^1.0.1", - "npm-package-arg": "^9.0.0", - "semver": "^7.3.5" - } - }, - "npm-profile": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-registry-fetch": { - "version": "13.0.1", - "bundled": true, - "dev": true, - "requires": { - "make-fetch-happen": "^10.0.3", - "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-user-validate": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npmlog": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.0", - "set-blocking": "^2.0.0" - } - }, - "once": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "opener": { - "version": "1.5.2", - "bundled": true, - "dev": true - }, - "p-map": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "pacote": { - "version": "13.0.3", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/promise-spawn": "^1.2.0", - "@npmcli/run-script": "^3.0.0", - "cacache": "^15.3.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "infer-owner": "^1.0.4", - "minipass": "^3.1.6", - "mkdirp": "^1.0.4", - "npm-package-arg": "^9.0.0", - "npm-packlist": "^3.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0", - "promise-retry": "^2.0.1", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.1.11" - } - }, - "parse-conflict-json": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1", - "just-diff": "^5.0.1", - "just-diff-apply": "^4.0.1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "proc-log": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "promise-all-reject-late": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-call-limit": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-inflight": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-retry": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "promzard": { - "version": "0.3.0", - "bundled": true, - "dev": true, - "requires": { - "read": "1" - } - }, - "qrcode-terminal": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "read": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "mute-stream": "~0.0.4" - } - }, - "read-cmd-shim": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "read-package-json": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.1", - "json-parse-even-better-errors": "^2.3.0", - "normalize-package-data": "^3.0.0", - "npm-normalize-package-bin": "^1.0.0" - } - }, - "read-package-json-fast": { - "version": "2.0.3", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.0", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "readable-stream": { - "version": "3.6.0", - "bundled": true, - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "readdir-scoped-modules": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "debuglog": "^1.0.1", - "dezalgo": "^1.0.0", - "graceful-fs": "^4.1.2", - "once": "^1.3.0" - } - }, - "retry": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.2.1", - "bundled": true, - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "semver": { - "version": "7.3.5", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "signal-exit": { - "version": "3.0.7", - "bundled": true, - "dev": true - }, - "smart-buffer": { - "version": "4.2.0", - "bundled": true, - "dev": true - }, - "socks": { - "version": "2.6.2", - "bundled": true, - "dev": true, - "requires": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "6.1.1", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "^6.0.2", - "debug": "^4.3.1", - "socks": "^2.6.1" - } - }, - "spdx-correct": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "bundled": true, - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.11", - "bundled": true, - "dev": true - }, - "ssri": { - "version": "8.0.1", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.1.1" - } - }, - "string-width": { - "version": "4.2.3", - "bundled": true, - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "string_decoder": { - "version": "1.3.0", - "bundled": true, - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "stringify-package": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tar": { - "version": "6.1.11", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "text-table": { - "version": "0.2.0", - "bundled": true, - "dev": true - }, - "tiny-relative-date": { - "version": "1.3.0", - "bundled": true, - "dev": true - }, - "treeverse": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "unique-filename": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "requires": { - "unique-slug": "^2.0.0" - } - }, - "unique-slug": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "bundled": true, - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validate-npm-package-name": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "builtins": "^1.0.3" - } - }, - "walk-up-path": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "wcwidth": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "defaults": "^1.0.3" - } - }, - "which": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wide-align": { - "version": "1.1.5", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "write-file-atomic": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - }, - "yallist": { - "version": "4.0.0", - "bundled": true, - "dev": true - } - } - }, - "semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", - "dev": true - } - } -} diff --git a/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb b/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb index cc177204a3..288cd553b1 100644 --- a/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb +++ b/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb @@ -24,11 +24,6 @@ spec: operator: In values: - linux - - matchExpressions: - - key: beta.kubernetes.io/os - operator: In - values: - - linux # <%- if config.node_selector -%> # nodeSelector: # <%- node_selector.to_h.each do |key, value| -%> diff --git a/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb b/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb index 2ff46d8d0b..c02fb93321 100644 --- a/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb +++ b/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb @@ -30,11 +30,6 @@ spec: operator: In values: - linux - - matchExpressions: - - key: beta.kubernetes.io/os - operator: In - values: - - linux securityContext: runAsNonRoot: true runAsUser: 65534 diff --git a/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb b/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb index 5eaefe2cf9..0174d5c8f4 100644 --- a/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb +++ b/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb @@ -23,11 +23,6 @@ spec: operator: In values: - linux - - matchExpressions: - - key: beta.kubernetes.io/os - operator: In - values: - - linux serviceAccountName: kube-state-metrics containers: - name: kube-state-metrics diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index dfd943797e..3f20a80df1 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -208,14 +208,14 @@ export class MetricsSettings extends React.Component {
this.togglePrometheus(v.target.checked)} name="prometheus" /> - } + )} label="Enable bundled Prometheus metrics stack" /> @@ -226,14 +226,14 @@ export class MetricsSettings extends React.Component {
this.toggleKubeStateMetrics(v.target.checked)} name="node-exporter" /> - } + )} label="Enable bundled kube-state-metrics stack" /> @@ -245,14 +245,14 @@ export class MetricsSettings extends React.Component {
this.toggleNodeExporter(v.target.checked)} name="node-exporter" /> - } + )} label="Enable bundled node-exporter stack" /> @@ -271,9 +271,11 @@ export class MetricsSettings extends React.Component { className="w-60 h-14" /> - {this.canUpgrade && ( - An update is available for enabled metrics components. - )} + {this.canUpgrade && ( + + An update is available for enabled metrics components. + + )}
); diff --git a/extensions/node-menu/package-lock.json b/extensions/node-menu/package-lock.json deleted file mode 100644 index 9885b4a885..0000000000 --- a/extensions/node-menu/package-lock.json +++ /dev/null @@ -1,2374 +0,0 @@ -{ - "name": "lens-node-menu", - "version": "0.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@k8slens/extensions": { - "version": "file:../../src/extensions/npm/extensions", - "dev": true, - "requires": { - "@material-ui/core": "4.12.3", - "@types/node": "14.17.14", - "@types/react-select": "3.1.2", - "conf": "^7.0.1", - "typed-emitter": "^1.3.1" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.16.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz", - "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "dev": true - }, - "@material-ui/core": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", - "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/styles": "^4.11.4", - "@material-ui/system": "^4.12.1", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "@types/react-transition-group": "^4.2.0", - "clsx": "^1.0.4", - "hoist-non-react-statics": "^3.3.2", - "popper.js": "1.16.1-lts", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0", - "react-transition-group": "^4.4.0" - } - }, - "@material-ui/styles": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz", - "integrity": "sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@emotion/hash": "^0.8.0", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "clsx": "^1.0.4", - "csstype": "^2.5.2", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.5.1", - "jss-plugin-camel-case": "^10.5.1", - "jss-plugin-default-unit": "^10.5.1", - "jss-plugin-global": "^10.5.1", - "jss-plugin-nested": "^10.5.1", - "jss-plugin-props-sort": "^10.5.1", - "jss-plugin-rule-value-function": "^10.5.1", - "jss-plugin-vendor-prefixer": "^10.5.1", - "prop-types": "^15.7.2" - } - }, - "@material-ui/system": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.1.tgz", - "integrity": "sha512-lUdzs4q9kEXZGhbN7BptyiS1rLNHe6kG9o8Y307HCvF4sQxbCgpL2qi+gUk+yI8a2DNk48gISEQxoxpgph0xIw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.2", - "csstype": "^2.5.2", - "prop-types": "^15.7.2" - } - }, - "@material-ui/types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "dev": true - }, - "@material-ui/utils": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz", - "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" - } - }, - "@types/node": { - "version": "14.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", - "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==", - "dev": true - }, - "@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "dev": true - }, - "@types/react": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", - "integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", - "dev": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "@types/react-dom": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", - "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/react-select": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.1.2.tgz", - "integrity": "sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA==", - "dev": true, - "requires": { - "@types/react": "*", - "@types/react-dom": "*", - "@types/react-transition-group": "*" - } - }, - "@types/react-transition-group": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", - "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "dev": true - }, - "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", - "dev": true - }, - "conf": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", - "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", - "dev": true, - "requires": { - "ajv": "^6.12.2", - "atomically": "^1.3.1", - "debounce-fn": "^4.0.0", - "dot-prop": "^5.2.0", - "env-paths": "^2.2.0", - "json-schema-typed": "^7.0.3", - "make-dir": "^3.1.0", - "onetime": "^5.1.0", - "pkg-up": "^3.1.0", - "semver": "^7.3.2" - } - }, - "css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", - "dev": true - }, - "debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dev": true, - "requires": { - "mimic-fn": "^3.0.0" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "requires": { - "is-obj": "^2.0.0" - } - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dev": true, - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", - "dev": true - }, - "is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=", - "dev": true - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", - "dev": true - }, - "jss": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.8.2.tgz", - "integrity": "sha512-FkoUNxI329CKQ9OQC8L72MBF9KPf5q8mIupAJ5twU7G7XREW7ahb+7jFfrjZ4iy1qvhx1HwIWUIvkZBDnKkEdQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "jss-plugin-camel-case": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.8.2.tgz", - "integrity": "sha512-2INyxR+1UdNuKf4v9It3tNfPvf7IPrtkiwzofeKuMd5D58/dxDJVUQYRVg/n460rTlHUfsEQx43hDrcxi9dSPA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.8.2" - } - }, - "jss-plugin-default-unit": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.8.2.tgz", - "integrity": "sha512-UZ7cwT9NFYSG+SEy7noRU50s4zifulFdjkUNKE+u6mW7vFP960+RglWjTgMfh79G6OENZmaYnjHV/gcKV4nSxg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-global": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.8.2.tgz", - "integrity": "sha512-UaYMSPsYZ7s/ECGoj4KoHC2jwQd5iQ7K+FFGnCAILdQrv7hPmvM2Ydg45ThT/sH46DqktCRV2SqjRuxeBH8nRA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-nested": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.8.2.tgz", - "integrity": "sha512-acRvuPJOb930fuYmhkJaa994EADpt8TxI63Iyg96C8FJ9T2xRyU5T6R1IYKRwUiqZo+2Sr7fdGzRTDD4uBZaMA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-props-sort": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.8.2.tgz", - "integrity": "sha512-wqdcjayKRWBZnNpLUrXvsWqh+5J5YToAQ+8HNBNw0kZxVvCDwzhK2Nx6AKs7p+5/MbAh2PLgNW5Ym/ysbVAuqQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-rule-value-function": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.8.2.tgz", - "integrity": "sha512-bW0EKAs+0HXpb6BKJhrn94IDdiWb0CnSluTkh0rGEgyzY/nmD1uV/Wf6KGlesGOZ9gmJzQy+9FFdxIUID1c9Ug==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-vendor-prefixer": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.8.2.tgz", - "integrity": "sha512-DeGv18QsSiYLSVIEB2+l0af6OToUe0JB+trpzUxyqD2QRC/5AzzDrCrYffO5AHZ81QbffYvSN/pkfZaTWpRXlg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.8.2" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - }, - "dependencies": { - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - } - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, - "pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "requires": { - "find-up": "^3.0.0" - } - }, - "popper.js": { - "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", - "dev": true - }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - } - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "dev": true - }, - "typed-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.3.1.tgz", - "integrity": "sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ==", - "dev": true - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "npm": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/npm/-/npm-8.5.3.tgz", - "integrity": "sha512-O+1j66Alx7ZQgWnUSSTaz8rTqQrJnqNb8Num5uQw2vYvc2RrxLaX7cWtRkDhvkPIL8Nf2WU9gx1oSu268QConA==", - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/config": "^4.0.1", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.1", - "abbrev": "~1.1.1", - "ansicolors": "~0.3.2", - "ansistyles": "~0.1.3", - "archy": "~1.0.0", - "cacache": "^15.3.0", - "chalk": "^4.1.2", - "chownr": "^2.0.0", - "cli-columns": "^4.0.0", - "cli-table3": "^0.6.1", - "columnify": "^1.6.0", - "fastest-levenshtein": "^1.0.12", - "glob": "^7.2.0", - "graceful-fs": "^4.2.9", - "hosted-git-info": "^4.1.0", - "ini": "^2.0.0", - "init-package-json": "^3.0.0", - "is-cidr": "^4.0.2", - "json-parse-even-better-errors": "^2.3.1", - "libnpmaccess": "^6.0.0", - "libnpmdiff": "^4.0.0", - "libnpmexec": "^4.0.0", - "libnpmfund": "^3.0.0", - "libnpmhook": "^8.0.0", - "libnpmorg": "^4.0.0", - "libnpmpack": "^4.0.0", - "libnpmpublish": "^6.0.0", - "libnpmsearch": "^5.0.0", - "libnpmteam": "^4.0.0", - "libnpmversion": "^3.0.0", - "make-fetch-happen": "^10.0.4", - "minipass": "^3.1.6", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "ms": "^2.1.2", - "node-gyp": "^9.0.0", - "nopt": "^5.0.0", - "npm-audit-report": "^2.1.5", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-profile": "^6.0.2", - "npm-registry-fetch": "^13.0.1", - "npm-user-validate": "^1.0.1", - "npmlog": "^6.0.1", - "opener": "^1.5.2", - "pacote": "^13.0.3", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "qrcode-terminal": "^0.12.0", - "read": "~1.0.7", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "tar": "^6.1.11", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^1.0.4", - "validate-npm-package-name": "~3.0.0", - "which": "^2.0.2", - "write-file-atomic": "^4.0.1" - }, - "dependencies": { - "@gar/promisify": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "@isaacs/string-locale-compare": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "@npmcli/arborist": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/metavuln-calculator": "^3.0.0", - "@npmcli/move-file": "^1.1.0", - "@npmcli/name-from-folder": "^1.0.1", - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.0", - "bin-links": "^3.0.0", - "cacache": "^15.0.3", - "common-ancestor-path": "^1.0.1", - "json-parse-even-better-errors": "^2.3.1", - "json-stringify-nice": "^1.1.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^1.0.1", - "read-package-json-fast": "^2.0.2", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "treeverse": "^1.0.4", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/ci-detect": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "@npmcli/config": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/map-workspaces": "^2.0.1", - "ini": "^2.0.0", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "proc-log": "^2.0.0", - "read-package-json-fast": "^2.0.3", - "semver": "^7.3.5", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/disparity-colors": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.3.0" - } - }, - "@npmcli/fs": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/git": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/promise-spawn": "^1.3.2", - "lru-cache": "^7.3.1", - "mkdirp": "^1.0.4", - "npm-pick-manifest": "^7.0.0", - "proc-log": "^2.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^2.0.2" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "@npmcli/installed-package-contents": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "@npmcli/map-workspaces": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/name-from-folder": "^1.0.1", - "glob": "^7.2.0", - "minimatch": "^5.0.0", - "read-package-json-fast": "^2.0.3" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "5.0.1", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@npmcli/metavuln-calculator": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cacache": "^15.3.0", - "json-parse-even-better-errors": "^2.3.1", - "pacote": "^13.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/move-file": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - } - }, - "@npmcli/name-from-folder": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "@npmcli/node-gyp": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "@npmcli/package-json": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1" - } - }, - "@npmcli/promise-spawn": { - "version": "1.3.2", - "bundled": true, - "dev": true, - "requires": { - "infer-owner": "^1.0.4" - } - }, - "@npmcli/run-script": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/promise-spawn": "^1.3.2", - "node-gyp": "^9.0.0", - "read-package-json-fast": "^2.0.3" - } - }, - "@tootallnate/once": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "abbrev": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "agent-base": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "debug": "4" - } - }, - "agentkeepalive": { - "version": "4.2.1", - "bundled": true, - "dev": true, - "requires": { - "debug": "^4.1.0", - "depd": "^1.1.2", - "humanize-ms": "^1.2.1" - } - }, - "aggregate-error": { - "version": "3.1.0", - "bundled": true, - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, - "ansi-styles": { - "version": "4.3.0", - "bundled": true, - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "ansicolors": { - "version": "0.3.2", - "bundled": true, - "dev": true - }, - "ansistyles": { - "version": "0.1.3", - "bundled": true, - "dev": true - }, - "aproba": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "archy": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "are-we-there-yet": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "asap": { - "version": "2.0.6", - "bundled": true, - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "bin-links": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cmd-shim": "^4.0.1", - "mkdirp-infer-owner": "^2.0.0", - "npm-normalize-package-bin": "^1.0.0", - "read-cmd-shim": "^2.0.0", - "rimraf": "^3.0.0", - "write-file-atomic": "^4.0.0" - } - }, - "binary-extensions": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "builtins": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "cacache": { - "version": "15.3.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - } - }, - "chalk": { - "version": "4.1.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chownr": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "cidr-regex": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "ip-regex": "^4.1.0" - } - }, - "clean-stack": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "cli-columns": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "cli-table3": { - "version": "0.6.1", - "bundled": true, - "dev": true, - "requires": { - "colors": "1.4.0", - "string-width": "^4.2.0" - } - }, - "clone": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "cmd-shim": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "mkdirp-infer-owner": "^2.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "color-support": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "colors": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "optional": true - }, - "columnify": { - "version": "1.6.0", - "bundled": true, - "dev": true, - "requires": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "common-ancestor-path": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true, - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "debug": { - "version": "4.3.3", - "bundled": true, - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "bundled": true, - "dev": true - } - } - }, - "debuglog": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "defaults": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "clone": "^1.0.2" - } - }, - "delegates": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "depd": { - "version": "1.1.2", - "bundled": true, - "dev": true - }, - "dezalgo": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "diff": { - "version": "5.0.0", - "bundled": true, - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "bundled": true, - "dev": true - }, - "encoding": { - "version": "0.1.13", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - } - }, - "env-paths": { - "version": "2.2.1", - "bundled": true, - "dev": true - }, - "err-code": { - "version": "2.0.3", - "bundled": true, - "dev": true - }, - "fastest-levenshtein": { - "version": "1.0.12", - "bundled": true, - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "function-bind": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "gauge": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1", - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "glob": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "graceful-fs": { - "version": "4.2.9", - "bundled": true, - "dev": true - }, - "has": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "has-unicode": { - "version": "2.0.1", - "bundled": true, - "dev": true - }, - "hosted-git-info": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "http-cache-semantics": { - "version": "4.1.0", - "bundled": true, - "dev": true - }, - "http-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "humanize-ms": { - "version": "1.2.1", - "bundled": true, - "dev": true, - "requires": { - "ms": "^2.0.0" - } - }, - "iconv-lite": { - "version": "0.6.3", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ignore-walk": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "imurmurhash": { - "version": "0.1.4", - "bundled": true, - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "infer-owner": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "bundled": true, - "dev": true - }, - "ini": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "init-package-json": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-package-arg": "^9.0.0", - "promzard": "^0.3.0", - "read": "^1.0.7", - "read-package-json": "^4.1.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^3.0.0" - } - }, - "ip": { - "version": "1.1.5", - "bundled": true, - "dev": true - }, - "ip-regex": { - "version": "4.3.0", - "bundled": true, - "dev": true - }, - "is-cidr": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "cidr-regex": "^3.1.1" - } - }, - "is-core-module": { - "version": "2.8.1", - "bundled": true, - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "bundled": true, - "dev": true - }, - "is-lambda": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "isexe": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "bundled": true, - "dev": true - }, - "json-stringify-nice": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "jsonparse": { - "version": "1.3.1", - "bundled": true, - "dev": true - }, - "just-diff": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "just-diff-apply": { - "version": "4.0.1", - "bundled": true, - "dev": true - }, - "libnpmaccess": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "minipass": "^3.1.1", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmdiff": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/disparity-colors": "^1.0.1", - "@npmcli/installed-package-contents": "^1.0.7", - "binary-extensions": "^2.2.0", - "diff": "^5.0.0", - "minimatch": "^3.0.4", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2", - "tar": "^6.1.0" - } - }, - "libnpmexec": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/run-script": "^3.0.0", - "chalk": "^4.1.0", - "mkdirp-infer-owner": "^2.0.0", - "npm-package-arg": "^9.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "proc-log": "^2.0.0", - "read": "^1.0.7", - "read-package-json-fast": "^2.0.2", - "walk-up-path": "^1.0.0" - } - }, - "libnpmfund": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0" - } - }, - "libnpmhook": { - "version": "8.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmorg": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmpack": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/run-script": "^3.0.0", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2" - } - }, - "libnpmpublish": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "normalize-package-data": "^3.0.2", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0", - "semver": "^7.1.3", - "ssri": "^8.0.1" - } - }, - "libnpmsearch": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmteam": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmversion": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/run-script": "^3.0.0", - "json-parse-even-better-errors": "^2.3.1", - "proc-log": "^2.0.0", - "semver": "^7.3.5", - "stringify-package": "^1.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-fetch-happen": { - "version": "10.0.4", - "bundled": true, - "dev": true, - "requires": { - "agentkeepalive": "^4.2.1", - "cacache": "^15.3.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.4.0", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.1.1", - "ssri": "^8.0.1" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "minimatch": { - "version": "3.1.2", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minipass": { - "version": "3.1.6", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minipass-collect": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-fetch": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "encoding": "^0.1.13", - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - } - }, - "minipass-flush": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-json-stream": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-sized": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mkdirp": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "mkdirp-infer-owner": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "infer-owner": "^1.0.4", - "mkdirp": "^1.0.3" - } - }, - "ms": { - "version": "2.1.3", - "bundled": true, - "dev": true - }, - "mute-stream": { - "version": "0.0.8", - "bundled": true, - "dev": true - }, - "negotiator": { - "version": "0.6.3", - "bundled": true, - "dev": true - }, - "node-gyp": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - } - }, - "nopt": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "3.0.3", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - } - }, - "npm-audit-report": { - "version": "2.1.5", - "bundled": true, - "dev": true, - "requires": { - "chalk": "^4.0.0" - } - }, - "npm-bundled": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-install-checks": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "semver": "^7.1.1" - } - }, - "npm-normalize-package-bin": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npm-package-arg": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.1.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^3.0.0" - } - }, - "npm-packlist": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.6", - "ignore-walk": "^4.0.1", - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-pick-manifest": { - "version": "7.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-install-checks": "^4.0.0", - "npm-normalize-package-bin": "^1.0.1", - "npm-package-arg": "^9.0.0", - "semver": "^7.3.5" - } - }, - "npm-profile": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-registry-fetch": { - "version": "13.0.1", - "bundled": true, - "dev": true, - "requires": { - "make-fetch-happen": "^10.0.3", - "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-user-validate": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npmlog": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.0", - "set-blocking": "^2.0.0" - } - }, - "once": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "opener": { - "version": "1.5.2", - "bundled": true, - "dev": true - }, - "p-map": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "pacote": { - "version": "13.0.3", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/promise-spawn": "^1.2.0", - "@npmcli/run-script": "^3.0.0", - "cacache": "^15.3.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "infer-owner": "^1.0.4", - "minipass": "^3.1.6", - "mkdirp": "^1.0.4", - "npm-package-arg": "^9.0.0", - "npm-packlist": "^3.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0", - "promise-retry": "^2.0.1", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.1.11" - } - }, - "parse-conflict-json": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1", - "just-diff": "^5.0.1", - "just-diff-apply": "^4.0.1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "proc-log": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "promise-all-reject-late": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-call-limit": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-inflight": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-retry": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "promzard": { - "version": "0.3.0", - "bundled": true, - "dev": true, - "requires": { - "read": "1" - } - }, - "qrcode-terminal": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "read": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "mute-stream": "~0.0.4" - } - }, - "read-cmd-shim": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "read-package-json": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.1", - "json-parse-even-better-errors": "^2.3.0", - "normalize-package-data": "^3.0.0", - "npm-normalize-package-bin": "^1.0.0" - } - }, - "read-package-json-fast": { - "version": "2.0.3", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.0", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "readable-stream": { - "version": "3.6.0", - "bundled": true, - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "readdir-scoped-modules": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "debuglog": "^1.0.1", - "dezalgo": "^1.0.0", - "graceful-fs": "^4.1.2", - "once": "^1.3.0" - } - }, - "retry": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.2.1", - "bundled": true, - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "semver": { - "version": "7.3.5", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "signal-exit": { - "version": "3.0.7", - "bundled": true, - "dev": true - }, - "smart-buffer": { - "version": "4.2.0", - "bundled": true, - "dev": true - }, - "socks": { - "version": "2.6.2", - "bundled": true, - "dev": true, - "requires": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "6.1.1", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "^6.0.2", - "debug": "^4.3.1", - "socks": "^2.6.1" - } - }, - "spdx-correct": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "bundled": true, - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.11", - "bundled": true, - "dev": true - }, - "ssri": { - "version": "8.0.1", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.1.1" - } - }, - "string-width": { - "version": "4.2.3", - "bundled": true, - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "string_decoder": { - "version": "1.3.0", - "bundled": true, - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "stringify-package": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tar": { - "version": "6.1.11", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "text-table": { - "version": "0.2.0", - "bundled": true, - "dev": true - }, - "tiny-relative-date": { - "version": "1.3.0", - "bundled": true, - "dev": true - }, - "treeverse": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "unique-filename": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "requires": { - "unique-slug": "^2.0.0" - } - }, - "unique-slug": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "bundled": true, - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validate-npm-package-name": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "builtins": "^1.0.3" - } - }, - "walk-up-path": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "wcwidth": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "defaults": "^1.0.3" - } - }, - "which": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wide-align": { - "version": "1.1.5", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "write-file-atomic": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - }, - "yallist": { - "version": "4.0.0", - "bundled": true, - "dev": true - } - } - } - } -} diff --git a/extensions/node-menu/src/node-menu.tsx b/extensions/node-menu/src/node-menu.tsx index 9666ad36e0..adc7576206 100644 --- a/extensions/node-menu/src/node-menu.tsx +++ b/extensions/node-menu/src/node-menu.tsx @@ -68,7 +68,9 @@ export function NodeMenu(props: NodeMenuProps) { labelOk: `Drain Node`, message: (

- Are you sure you want to drain {nodeName}? + {"Are you sure you want to drain "} + {nodeName} + ?

), }); @@ -77,26 +79,42 @@ export function NodeMenu(props: NodeMenuProps) { return ( <> - + Shell { node.isUnschedulable() ? ( - + Uncordon ) : ( - + Cordon ) } - + Drain diff --git a/extensions/pod-menu/package-lock.json b/extensions/pod-menu/package-lock.json deleted file mode 100644 index 8a7e5e4544..0000000000 --- a/extensions/pod-menu/package-lock.json +++ /dev/null @@ -1,2367 +0,0 @@ -{ - "name": "lens-pod-menu", - "version": "0.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@k8slens/extensions": { - "version": "file:../../src/extensions/npm/extensions", - "dev": true, - "requires": { - "@material-ui/core": "4.12.3", - "@types/node": "14.17.14", - "@types/react-select": "3.1.2", - "conf": "^7.0.1", - "typed-emitter": "^1.3.1" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.16.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz", - "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "dev": true - }, - "@material-ui/core": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", - "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/styles": "^4.11.4", - "@material-ui/system": "^4.12.1", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "@types/react-transition-group": "^4.2.0", - "clsx": "^1.0.4", - "hoist-non-react-statics": "^3.3.2", - "popper.js": "1.16.1-lts", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0", - "react-transition-group": "^4.4.0" - } - }, - "@material-ui/styles": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz", - "integrity": "sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@emotion/hash": "^0.8.0", - "@material-ui/types": "5.1.0", - "@material-ui/utils": "^4.11.2", - "clsx": "^1.0.4", - "csstype": "^2.5.2", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.5.1", - "jss-plugin-camel-case": "^10.5.1", - "jss-plugin-default-unit": "^10.5.1", - "jss-plugin-global": "^10.5.1", - "jss-plugin-nested": "^10.5.1", - "jss-plugin-props-sort": "^10.5.1", - "jss-plugin-rule-value-function": "^10.5.1", - "jss-plugin-vendor-prefixer": "^10.5.1", - "prop-types": "^15.7.2" - } - }, - "@material-ui/system": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.1.tgz", - "integrity": "sha512-lUdzs4q9kEXZGhbN7BptyiS1rLNHe6kG9o8Y307HCvF4sQxbCgpL2qi+gUk+yI8a2DNk48gISEQxoxpgph0xIw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.2", - "csstype": "^2.5.2", - "prop-types": "^15.7.2" - } - }, - "@material-ui/types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "dev": true - }, - "@material-ui/utils": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz", - "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.4.4", - "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" - } - }, - "@types/node": { - "version": "14.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", - "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==", - "dev": true - }, - "@types/prop-types": { - "version": "15.7.3", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", - "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", - "dev": true - }, - "@types/react": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz", - "integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==", - "dev": true, - "requires": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", - "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==", - "dev": true - } - } - }, - "@types/react-dom": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz", - "integrity": "sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/react-select": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.1.2.tgz", - "integrity": "sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA==", - "dev": true, - "requires": { - "@types/react": "*", - "@types/react-dom": "*", - "@types/react-transition-group": "*" - } - }, - "@types/react-transition-group": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", - "integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "dev": true - }, - "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", - "dev": true - }, - "conf": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", - "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", - "dev": true, - "requires": { - "ajv": "^6.12.2", - "atomically": "^1.3.1", - "debounce-fn": "^4.0.0", - "dot-prop": "^5.2.0", - "env-paths": "^2.2.0", - "json-schema-typed": "^7.0.3", - "make-dir": "^3.1.0", - "onetime": "^5.1.0", - "pkg-up": "^3.1.0", - "semver": "^7.3.2" - } - }, - "css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", - "dev": true - }, - "debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dev": true, - "requires": { - "mimic-fn": "^3.0.0" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "requires": { - "is-obj": "^2.0.0" - } - }, - "env-paths": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz", - "integrity": "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dev": true, - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", - "dev": true - }, - "is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=", - "dev": true - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", - "dev": true - }, - "jss": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.8.2.tgz", - "integrity": "sha512-FkoUNxI329CKQ9OQC8L72MBF9KPf5q8mIupAJ5twU7G7XREW7ahb+7jFfrjZ4iy1qvhx1HwIWUIvkZBDnKkEdQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" - }, - "dependencies": { - "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true - } - } - }, - "jss-plugin-camel-case": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.8.2.tgz", - "integrity": "sha512-2INyxR+1UdNuKf4v9It3tNfPvf7IPrtkiwzofeKuMd5D58/dxDJVUQYRVg/n460rTlHUfsEQx43hDrcxi9dSPA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.8.2" - } - }, - "jss-plugin-default-unit": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.8.2.tgz", - "integrity": "sha512-UZ7cwT9NFYSG+SEy7noRU50s4zifulFdjkUNKE+u6mW7vFP960+RglWjTgMfh79G6OENZmaYnjHV/gcKV4nSxg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-global": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.8.2.tgz", - "integrity": "sha512-UaYMSPsYZ7s/ECGoj4KoHC2jwQd5iQ7K+FFGnCAILdQrv7hPmvM2Ydg45ThT/sH46DqktCRV2SqjRuxeBH8nRA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-nested": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.8.2.tgz", - "integrity": "sha512-acRvuPJOb930fuYmhkJaa994EADpt8TxI63Iyg96C8FJ9T2xRyU5T6R1IYKRwUiqZo+2Sr7fdGzRTDD4uBZaMA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-props-sort": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.8.2.tgz", - "integrity": "sha512-wqdcjayKRWBZnNpLUrXvsWqh+5J5YToAQ+8HNBNw0kZxVvCDwzhK2Nx6AKs7p+5/MbAh2PLgNW5Ym/ysbVAuqQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2" - } - }, - "jss-plugin-rule-value-function": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.8.2.tgz", - "integrity": "sha512-bW0EKAs+0HXpb6BKJhrn94IDdiWb0CnSluTkh0rGEgyzY/nmD1uV/Wf6KGlesGOZ9gmJzQy+9FFdxIUID1c9Ug==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "jss": "10.8.2", - "tiny-warning": "^1.0.2" - } - }, - "jss-plugin-vendor-prefixer": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.8.2.tgz", - "integrity": "sha512-DeGv18QsSiYLSVIEB2+l0af6OToUe0JB+trpzUxyqD2QRC/5AzzDrCrYffO5AHZ81QbffYvSN/pkfZaTWpRXlg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.8.2" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - }, - "dependencies": { - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - } - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, - "pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "requires": { - "find-up": "^3.0.0" - } - }, - "popper.js": { - "version": "1.16.1-lts", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", - "dev": true - }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - } - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "react-transition-group": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", - "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - } - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true - }, - "semver": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", - "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "dev": true - }, - "typed-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.3.1.tgz", - "integrity": "sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ==", - "dev": true - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "npm": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/npm/-/npm-8.5.3.tgz", - "integrity": "sha512-O+1j66Alx7ZQgWnUSSTaz8rTqQrJnqNb8Num5uQw2vYvc2RrxLaX7cWtRkDhvkPIL8Nf2WU9gx1oSu268QConA==", - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/config": "^4.0.1", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.1", - "abbrev": "~1.1.1", - "ansicolors": "~0.3.2", - "ansistyles": "~0.1.3", - "archy": "~1.0.0", - "cacache": "^15.3.0", - "chalk": "^4.1.2", - "chownr": "^2.0.0", - "cli-columns": "^4.0.0", - "cli-table3": "^0.6.1", - "columnify": "^1.6.0", - "fastest-levenshtein": "^1.0.12", - "glob": "^7.2.0", - "graceful-fs": "^4.2.9", - "hosted-git-info": "^4.1.0", - "ini": "^2.0.0", - "init-package-json": "^3.0.0", - "is-cidr": "^4.0.2", - "json-parse-even-better-errors": "^2.3.1", - "libnpmaccess": "^6.0.0", - "libnpmdiff": "^4.0.0", - "libnpmexec": "^4.0.0", - "libnpmfund": "^3.0.0", - "libnpmhook": "^8.0.0", - "libnpmorg": "^4.0.0", - "libnpmpack": "^4.0.0", - "libnpmpublish": "^6.0.0", - "libnpmsearch": "^5.0.0", - "libnpmteam": "^4.0.0", - "libnpmversion": "^3.0.0", - "make-fetch-happen": "^10.0.4", - "minipass": "^3.1.6", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "ms": "^2.1.2", - "node-gyp": "^9.0.0", - "nopt": "^5.0.0", - "npm-audit-report": "^2.1.5", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-profile": "^6.0.2", - "npm-registry-fetch": "^13.0.1", - "npm-user-validate": "^1.0.1", - "npmlog": "^6.0.1", - "opener": "^1.5.2", - "pacote": "^13.0.3", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "qrcode-terminal": "^0.12.0", - "read": "~1.0.7", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "tar": "^6.1.11", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^1.0.4", - "validate-npm-package-name": "~3.0.0", - "which": "^2.0.2", - "write-file-atomic": "^4.0.1" - }, - "dependencies": { - "@gar/promisify": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "@isaacs/string-locale-compare": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "@npmcli/arborist": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/map-workspaces": "^2.0.0", - "@npmcli/metavuln-calculator": "^3.0.0", - "@npmcli/move-file": "^1.1.0", - "@npmcli/name-from-folder": "^1.0.1", - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/package-json": "^1.0.1", - "@npmcli/run-script": "^3.0.0", - "bin-links": "^3.0.0", - "cacache": "^15.0.3", - "common-ancestor-path": "^1.0.1", - "json-parse-even-better-errors": "^2.3.1", - "json-stringify-nice": "^1.1.4", - "mkdirp": "^1.0.4", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "npm-install-checks": "^4.0.0", - "npm-package-arg": "^9.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "parse-conflict-json": "^2.0.1", - "proc-log": "^2.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^1.0.1", - "read-package-json-fast": "^2.0.2", - "readdir-scoped-modules": "^1.1.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "ssri": "^8.0.1", - "treeverse": "^1.0.4", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/ci-detect": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "@npmcli/config": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/map-workspaces": "^2.0.1", - "ini": "^2.0.0", - "mkdirp-infer-owner": "^2.0.0", - "nopt": "^5.0.0", - "proc-log": "^2.0.0", - "read-package-json-fast": "^2.0.3", - "semver": "^7.3.5", - "walk-up-path": "^1.0.0" - } - }, - "@npmcli/disparity-colors": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.3.0" - } - }, - "@npmcli/fs": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/git": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/promise-spawn": "^1.3.2", - "lru-cache": "^7.3.1", - "mkdirp": "^1.0.4", - "npm-pick-manifest": "^7.0.0", - "proc-log": "^2.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^2.0.2" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "@npmcli/installed-package-contents": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "@npmcli/map-workspaces": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/name-from-folder": "^1.0.1", - "glob": "^7.2.0", - "minimatch": "^5.0.0", - "read-package-json-fast": "^2.0.3" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "5.0.1", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@npmcli/metavuln-calculator": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cacache": "^15.3.0", - "json-parse-even-better-errors": "^2.3.1", - "pacote": "^13.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/move-file": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - } - }, - "@npmcli/name-from-folder": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "@npmcli/node-gyp": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "@npmcli/package-json": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1" - } - }, - "@npmcli/promise-spawn": { - "version": "1.3.2", - "bundled": true, - "dev": true, - "requires": { - "infer-owner": "^1.0.4" - } - }, - "@npmcli/run-script": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/node-gyp": "^1.0.3", - "@npmcli/promise-spawn": "^1.3.2", - "node-gyp": "^9.0.0", - "read-package-json-fast": "^2.0.3" - } - }, - "@tootallnate/once": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "abbrev": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "agent-base": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "debug": "4" - } - }, - "agentkeepalive": { - "version": "4.2.1", - "bundled": true, - "dev": true, - "requires": { - "debug": "^4.1.0", - "depd": "^1.1.2", - "humanize-ms": "^1.2.1" - } - }, - "aggregate-error": { - "version": "3.1.0", - "bundled": true, - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, - "ansi-styles": { - "version": "4.3.0", - "bundled": true, - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "ansicolors": { - "version": "0.3.2", - "bundled": true, - "dev": true - }, - "ansistyles": { - "version": "0.1.3", - "bundled": true, - "dev": true - }, - "aproba": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "archy": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "are-we-there-yet": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "asap": { - "version": "2.0.6", - "bundled": true, - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "bin-links": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "cmd-shim": "^4.0.1", - "mkdirp-infer-owner": "^2.0.0", - "npm-normalize-package-bin": "^1.0.0", - "read-cmd-shim": "^2.0.0", - "rimraf": "^3.0.0", - "write-file-atomic": "^4.0.0" - } - }, - "binary-extensions": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "builtins": { - "version": "1.0.3", - "bundled": true, - "dev": true - }, - "cacache": { - "version": "15.3.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - } - }, - "chalk": { - "version": "4.1.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chownr": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "cidr-regex": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "ip-regex": "^4.1.0" - } - }, - "clean-stack": { - "version": "2.2.0", - "bundled": true, - "dev": true - }, - "cli-columns": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "cli-table3": { - "version": "0.6.1", - "bundled": true, - "dev": true, - "requires": { - "colors": "1.4.0", - "string-width": "^4.2.0" - } - }, - "clone": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "cmd-shim": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "mkdirp-infer-owner": "^2.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "color-support": { - "version": "1.1.3", - "bundled": true, - "dev": true - }, - "colors": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "optional": true - }, - "columnify": { - "version": "1.6.0", - "bundled": true, - "dev": true, - "requires": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "common-ancestor-path": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true, - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "debug": { - "version": "4.3.3", - "bundled": true, - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "bundled": true, - "dev": true - } - } - }, - "debuglog": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "defaults": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "clone": "^1.0.2" - } - }, - "delegates": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "depd": { - "version": "1.1.2", - "bundled": true, - "dev": true - }, - "dezalgo": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "diff": { - "version": "5.0.0", - "bundled": true, - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "bundled": true, - "dev": true - }, - "encoding": { - "version": "0.1.13", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - } - }, - "env-paths": { - "version": "2.2.1", - "bundled": true, - "dev": true - }, - "err-code": { - "version": "2.0.3", - "bundled": true, - "dev": true - }, - "fastest-levenshtein": { - "version": "1.0.12", - "bundled": true, - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "function-bind": { - "version": "1.1.1", - "bundled": true, - "dev": true - }, - "gauge": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1", - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "glob": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "graceful-fs": { - "version": "4.2.9", - "bundled": true, - "dev": true - }, - "has": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "has-unicode": { - "version": "2.0.1", - "bundled": true, - "dev": true - }, - "hosted-git-info": { - "version": "4.1.0", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "http-cache-semantics": { - "version": "4.1.0", - "bundled": true, - "dev": true - }, - "http-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "humanize-ms": { - "version": "1.2.1", - "bundled": true, - "dev": true, - "requires": { - "ms": "^2.0.0" - } - }, - "iconv-lite": { - "version": "0.6.3", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ignore-walk": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "minimatch": "^3.0.4" - } - }, - "imurmurhash": { - "version": "0.1.4", - "bundled": true, - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, - "infer-owner": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "bundled": true, - "dev": true - }, - "ini": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "init-package-json": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-package-arg": "^9.0.0", - "promzard": "^0.3.0", - "read": "^1.0.7", - "read-package-json": "^4.1.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^3.0.0" - } - }, - "ip": { - "version": "1.1.5", - "bundled": true, - "dev": true - }, - "ip-regex": { - "version": "4.3.0", - "bundled": true, - "dev": true - }, - "is-cidr": { - "version": "4.0.2", - "bundled": true, - "dev": true, - "requires": { - "cidr-regex": "^3.1.1" - } - }, - "is-core-module": { - "version": "2.8.1", - "bundled": true, - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "bundled": true, - "dev": true - }, - "is-lambda": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "isexe": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "bundled": true, - "dev": true - }, - "json-stringify-nice": { - "version": "1.1.4", - "bundled": true, - "dev": true - }, - "jsonparse": { - "version": "1.3.1", - "bundled": true, - "dev": true - }, - "just-diff": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "just-diff-apply": { - "version": "4.0.1", - "bundled": true, - "dev": true - }, - "libnpmaccess": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "minipass": "^3.1.1", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmdiff": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/disparity-colors": "^1.0.1", - "@npmcli/installed-package-contents": "^1.0.7", - "binary-extensions": "^2.2.0", - "diff": "^5.0.0", - "minimatch": "^3.0.4", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2", - "tar": "^6.1.0" - } - }, - "libnpmexec": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0", - "@npmcli/ci-detect": "^2.0.0", - "@npmcli/run-script": "^3.0.0", - "chalk": "^4.1.0", - "mkdirp-infer-owner": "^2.0.0", - "npm-package-arg": "^9.0.0", - "npmlog": "^6.0.1", - "pacote": "^13.0.2", - "proc-log": "^2.0.0", - "read": "^1.0.7", - "read-package-json-fast": "^2.0.2", - "walk-up-path": "^1.0.0" - } - }, - "libnpmfund": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/arborist": "^5.0.0" - } - }, - "libnpmhook": { - "version": "8.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmorg": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmpack": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/run-script": "^3.0.0", - "npm-package-arg": "^9.0.0", - "pacote": "^13.0.2" - } - }, - "libnpmpublish": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "normalize-package-data": "^3.0.2", - "npm-package-arg": "^9.0.0", - "npm-registry-fetch": "^13.0.0", - "semver": "^7.1.3", - "ssri": "^8.0.1" - } - }, - "libnpmsearch": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmteam": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^13.0.0" - } - }, - "libnpmversion": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/run-script": "^3.0.0", - "json-parse-even-better-errors": "^2.3.1", - "proc-log": "^2.0.0", - "semver": "^7.3.5", - "stringify-package": "^1.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-fetch-happen": { - "version": "10.0.4", - "bundled": true, - "dev": true, - "requires": { - "agentkeepalive": "^4.2.1", - "cacache": "^15.3.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.4.0", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.1.1", - "ssri": "^8.0.1" - }, - "dependencies": { - "lru-cache": { - "version": "7.4.0", - "bundled": true, - "dev": true - } - } - }, - "minimatch": { - "version": "3.1.2", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minipass": { - "version": "3.1.6", - "bundled": true, - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minipass-collect": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-fetch": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "encoding": "^0.1.13", - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - } - }, - "minipass-flush": { - "version": "1.0.5", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-json-stream": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-sized": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mkdirp": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "mkdirp-infer-owner": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "infer-owner": "^1.0.4", - "mkdirp": "^1.0.3" - } - }, - "ms": { - "version": "2.1.3", - "bundled": true, - "dev": true - }, - "mute-stream": { - "version": "0.0.8", - "bundled": true, - "dev": true - }, - "negotiator": { - "version": "0.6.3", - "bundled": true, - "dev": true - }, - "node-gyp": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - } - }, - "nopt": { - "version": "5.0.0", - "bundled": true, - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "3.0.3", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - } - }, - "npm-audit-report": { - "version": "2.1.5", - "bundled": true, - "dev": true, - "requires": { - "chalk": "^4.0.0" - } - }, - "npm-bundled": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "requires": { - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-install-checks": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "semver": "^7.1.1" - } - }, - "npm-normalize-package-bin": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npm-package-arg": { - "version": "9.0.0", - "bundled": true, - "dev": true, - "requires": { - "hosted-git-info": "^4.1.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^3.0.0" - } - }, - "npm-packlist": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.6", - "ignore-walk": "^4.0.1", - "npm-bundled": "^1.1.1", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "npm-pick-manifest": { - "version": "7.0.0", - "bundled": true, - "dev": true, - "requires": { - "npm-install-checks": "^4.0.0", - "npm-normalize-package-bin": "^1.0.1", - "npm-package-arg": "^9.0.0", - "semver": "^7.3.5" - } - }, - "npm-profile": { - "version": "6.0.2", - "bundled": true, - "dev": true, - "requires": { - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-registry-fetch": { - "version": "13.0.1", - "bundled": true, - "dev": true, - "requires": { - "make-fetch-happen": "^10.0.3", - "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", - "proc-log": "^2.0.0" - } - }, - "npm-user-validate": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "npmlog": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.0", - "set-blocking": "^2.0.0" - } - }, - "once": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "opener": { - "version": "1.5.2", - "bundled": true, - "dev": true - }, - "p-map": { - "version": "4.0.0", - "bundled": true, - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "pacote": { - "version": "13.0.3", - "bundled": true, - "dev": true, - "requires": { - "@npmcli/git": "^3.0.0", - "@npmcli/installed-package-contents": "^1.0.7", - "@npmcli/promise-spawn": "^1.2.0", - "@npmcli/run-script": "^3.0.0", - "cacache": "^15.3.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "infer-owner": "^1.0.4", - "minipass": "^3.1.6", - "mkdirp": "^1.0.4", - "npm-package-arg": "^9.0.0", - "npm-packlist": "^3.0.0", - "npm-pick-manifest": "^7.0.0", - "npm-registry-fetch": "^13.0.0", - "proc-log": "^2.0.0", - "promise-retry": "^2.0.1", - "read-package-json": "^4.1.1", - "read-package-json-fast": "^2.0.3", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.1.11" - } - }, - "parse-conflict-json": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.1", - "just-diff": "^5.0.1", - "just-diff-apply": "^4.0.1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "proc-log": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "promise-all-reject-late": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-call-limit": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-inflight": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "promise-retry": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "promzard": { - "version": "0.3.0", - "bundled": true, - "dev": true, - "requires": { - "read": "1" - } - }, - "qrcode-terminal": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "read": { - "version": "1.0.7", - "bundled": true, - "dev": true, - "requires": { - "mute-stream": "~0.0.4" - } - }, - "read-cmd-shim": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "read-package-json": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.1", - "json-parse-even-better-errors": "^2.3.0", - "normalize-package-data": "^3.0.0", - "npm-normalize-package-bin": "^1.0.0" - } - }, - "read-package-json-fast": { - "version": "2.0.3", - "bundled": true, - "dev": true, - "requires": { - "json-parse-even-better-errors": "^2.3.0", - "npm-normalize-package-bin": "^1.0.1" - } - }, - "readable-stream": { - "version": "3.6.0", - "bundled": true, - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "readdir-scoped-modules": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "requires": { - "debuglog": "^1.0.1", - "dezalgo": "^1.0.0", - "graceful-fs": "^4.1.2", - "once": "^1.3.0" - } - }, - "retry": { - "version": "0.12.0", - "bundled": true, - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "bundled": true, - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.2.1", - "bundled": true, - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "semver": { - "version": "7.3.5", - "bundled": true, - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true, - "dev": true - }, - "signal-exit": { - "version": "3.0.7", - "bundled": true, - "dev": true - }, - "smart-buffer": { - "version": "4.2.0", - "bundled": true, - "dev": true - }, - "socks": { - "version": "2.6.2", - "bundled": true, - "dev": true, - "requires": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "6.1.1", - "bundled": true, - "dev": true, - "requires": { - "agent-base": "^6.0.2", - "debug": "^4.3.1", - "socks": "^2.6.1" - } - }, - "spdx-correct": { - "version": "3.1.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "bundled": true, - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.11", - "bundled": true, - "dev": true - }, - "ssri": { - "version": "8.0.1", - "bundled": true, - "dev": true, - "requires": { - "minipass": "^3.1.1" - } - }, - "string-width": { - "version": "4.2.3", - "bundled": true, - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "bundled": true, - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "string_decoder": { - "version": "1.3.0", - "bundled": true, - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "stringify-package": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "bundled": true, - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tar": { - "version": "6.1.11", - "bundled": true, - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "text-table": { - "version": "0.2.0", - "bundled": true, - "dev": true - }, - "tiny-relative-date": { - "version": "1.3.0", - "bundled": true, - "dev": true - }, - "treeverse": { - "version": "1.0.4", - "bundled": true, - "dev": true - }, - "unique-filename": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "requires": { - "unique-slug": "^2.0.0" - } - }, - "unique-slug": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "bundled": true, - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validate-npm-package-name": { - "version": "3.0.0", - "bundled": true, - "dev": true, - "requires": { - "builtins": "^1.0.3" - } - }, - "walk-up-path": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "wcwidth": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "defaults": "^1.0.3" - } - }, - "which": { - "version": "2.0.2", - "bundled": true, - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wide-align": { - "version": "1.1.5", - "bundled": true, - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "write-file-atomic": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - }, - "yallist": { - "version": "4.0.0", - "bundled": true, - "dev": true - } - } - } - } -} diff --git a/extensions/pod-menu/src/attach-menu.tsx b/extensions/pod-menu/src/attach-menu.tsx index de8035ed94..fd6250bd30 100644 --- a/extensions/pod-menu/src/attach-menu.tsx +++ b/extensions/pod-menu/src/attach-menu.tsx @@ -71,7 +71,11 @@ export class PodAttachMenu extends React.Component { return ( this.attachToPod(containers[0].name))}> - + Attach Pod {containers.length > 1 && ( <> @@ -82,7 +86,11 @@ export class PodAttachMenu extends React.Component { const { name } = container; return ( - this.attachToPod(name))} className="flex align-center"> + this.attachToPod(name))} + className="flex align-center" + > {name} diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index b7cc0851aa..e5c8496d3d 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -46,7 +46,11 @@ export class PodLogsMenu extends React.Component { return ( this.showLogs(containers[0]))}> - + Logs {containers.length > 1 && ( <> @@ -63,7 +67,11 @@ export class PodLogsMenu extends React.Component { ) : null; return ( - this.showLogs(container))} className="flex align-center"> + this.showLogs(container))} + className="flex align-center" + > {brick} {name} diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index 4e239d6fdf..36be7c470b 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -79,7 +79,11 @@ export class PodShellMenu extends React.Component { return ( this.execShell(containers[0].name))}> - + Shell {containers.length > 1 && ( <> @@ -90,7 +94,11 @@ export class PodShellMenu extends React.Component { const { name } = container; return ( - this.execShell(name))} className="flex align-center"> + this.execShell(name))} + className="flex align-center" + > {name} diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 981628b1ba..b56b999b2b 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -39,14 +39,14 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await frame.waitForSelector(`.Menu >> text="Remove"`); }); - it("opens cluster settings by clicking link in no-metrics area", async () => { + // FIXME: failed locally since metrics might already exist, cc @aleksfront + it.skip("opens cluster settings by clicking link in no-metrics area", async () => { await frame.locator("text=Open cluster settings >> nth=0").click(); await window.waitForSelector(`[data-testid="metrics-header"]`); }); it( "should navigate around common cluster pages", - async () => { const scenariosByParent = pipeline( scenarios, @@ -138,7 +138,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { ); it( - `should create the ${TEST_NAMESPACE} and a pod in the namespace`, + `should create the ${TEST_NAMESPACE} and a pod in the namespace and then remove that pod via the context menu`, async () => { await navigateToNamespaces(frame); await frame.click("button.add-button"); @@ -208,6 +208,10 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await frame.click(".Dock .Button >> text='Create'"); await frame.waitForSelector(`.TableCell >> text=${testPodName}`); + await frame.click(".TableRow .TableCell.menu"); + await frame.click(".MenuItem >> text=Delete"); + await frame.click("button >> text=Remove"); + await frame.waitForSelector(`.TableCell >> text=${testPodName}`, { state: "detached" }); }, 10 * 60 * 1000, ); diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts index b1d43bcd2e..7073d1d4cc 100644 --- a/integration/__tests__/command-palette.tests.ts +++ b/integration/__tests__/command-palette.tests.ts @@ -24,9 +24,9 @@ describe("Lens command palette", () => { utils.itIf(!isWindows)("opens command dialog from menu", async () => { await app.evaluate(async ({ app }) => { await app.applicationMenu - .getMenuItemById("view") - .submenu.getMenuItemById("command-palette") - .click(); + ?.getMenuItemById("view") + ?.submenu?.getMenuItemById("command-palette") + ?.click(); }); await window.waitForSelector(".Select__option >> text=Hotbar: Switch"); }, 10*60*1000); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 33dabede04..f8eca46c26 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -108,6 +108,10 @@ export async function lauchMinikubeClusterFromCatalog(window: Page): Promise/integration/\"]; }; func", "dist": "yarn run compile && electron-builder --publish onTag", "dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null", "download:binaries": "yarn run ts-node build/download_binaries.ts", - "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", + "build:tray-icons": "yarn run ts-node build/generate-tray-icons.ts", "build:theme-vars": "yarn run ts-node build/build_theme_vars.ts", "lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .", "lint:fix": "yarn run lint --fix", @@ -72,6 +72,9 @@ "/src/jest.setup.ts", "jest-canvas-mock" ], + "setupFilesAfterEnv": [ + "/src/jest-after-env.setup.ts" + ], "globals": { "ts-jest": { "isolatedModules": true @@ -203,9 +206,11 @@ "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.16.3", - "@ogre-tools/fp": "5.2.0", - "@ogre-tools/injectable": "5.2.0", - "@ogre-tools/injectable-react": "5.2.0", + "@material-ui/styles": "^4.11.5", + "@ogre-tools/injectable": "7.0.0", + "@ogre-tools/injectable-react": "7.0.0", + "@ogre-tools/fp": "7.0.0", + "@ogre-tools/injectable-extension-for-auto-registration": "7.0.0", "@sentry/electron": "^3.0.7", "@sentry/integrations": "^6.19.3", "@types/circular-dependency-plugin": "5.0.5", @@ -222,21 +227,22 @@ "filehound": "^1.17.6", "fs-extra": "^9.0.1", "glob-to-regexp": "^0.4.1", - "got": "^11.8.3", + "got": "^11.8.5", "grapheme-splitter": "^1.0.4", "handlebars": "^4.7.7", + "history": "^4.10.1", "http-proxy": "^1.18.1", - "immer": "^9.0.12", + "immer": "^9.0.14", "joi": "^17.6.0", "js-yaml": "^4.1.0", "jsdom": "^16.7.0", "lodash": "^4.17.15", "mac-ca": "^1.0.6", - "marked": "^4.0.15", + "marked": "^4.0.16", "md5-file": "^5.0.0", "mobx": "^6.5.0", "mobx-observable-history": "^2.0.3", - "mobx-react": "^7.3.0", + "mobx-react": "^7.5.0", "mobx-utils": "^6.0.4", "mock-fs": "^5.1.2", "moment": "^2.29.3", @@ -265,35 +271,38 @@ "tar": "^6.1.11", "tcp-port-used": "^1.0.2", "tempy": "1.0.1", + "typed-regex": "^0.0.8", "url-parse": "^1.5.10", "uuid": "^8.3.2", "win-ca": "^3.5.0", "winston": "^3.7.2", "winston-console-format": "^1.0.8", "winston-transport-browserconsole": "^1.0.5", - "ws": "^7.5.7" + "ws": "^8.7.0" }, "devDependencies": { - "@async-fn/jest": "1.5.3", + "@async-fn/jest": "1.6.1", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.5", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@sentry/types": "^6.19.7", + "@testing-library/dom": "^7.31.2", "@testing-library/jest-dom": "^5.16.4", - "@testing-library/react": "^12.1.4", + "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^13.5.0", "@types/byline": "^4.2.33", "@types/chart.js": "^2.9.36", - "@types/cli-progress": "^3.9.2", + "@types/circular-dependency-plugin": "5.0.5", + "@types/cli-progress": "^3.11.0", "@types/color": "^3.0.3", + "@types/command-line-args": "^5.2.0", "@types/crypto-js": "^3.1.47", "@types/dompurify": "^2.3.3", "@types/electron-devtools-installer": "^2.2.1", "@types/fs-extra": "^9.0.13", "@types/glob-to-regexp": "^0.4.1", "@types/gunzip-maybe": "^1.4.0", - "@types/hoist-non-react-statics": "^3.3.1", "@types/html-webpack-plugin": "^3.2.6", "@types/http-proxy": "^1.17.9", "@types/jest": "^26.0.24", @@ -304,17 +313,17 @@ "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^2.4.0", "@types/mock-fs": "^4.13.1", - "@types/node": "14.18.12", + "@types/node": "14.18.18", "@types/node-fetch": "^2.6.1", "@types/npm": "^2.0.32", "@types/proper-lockfile": "^4.1.2", "@types/randomcolor": "^0.5.6", - "@types/react": "^17.0.44", + "@types/react": "^17.0.45", "@types/react-beautiful-dnd": "^13.1.2", - "@types/react-dom": "^17.0.14", + "@types/react-dom": "^17.0.16", + "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", - "@types/react-select": "3.1.2", - "@types/react-table": "^7.7.11", + "@types/react-table": "^7.7.12", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", "@types/readable-stream": "^2.3.13", @@ -332,34 +341,34 @@ "@types/uuid": "^8.3.4", "@types/webpack": "^5.28.0", "@types/webpack-dev-server": "^4.7.2", - "@types/webpack-env": "^1.16.3", + "@types/webpack-env": "^1.17.0", "@types/webpack-node-externals": "^2.5.3", - "@typescript-eslint/eslint-plugin": "^5.21.0", - "@typescript-eslint/parser": "^5.17.0", + "@typescript-eslint/eslint-plugin": "^5.27.0", + "@typescript-eslint/parser": "^5.27.0", "ansi_up": "^5.1.0", "chart.js": "^2.9.4", "circular-dependency-plugin": "^5.2.2", - "cli-progress": "^3.11.0", + "cli-progress": "^3.11.1", "color": "^3.2.1", - "concurrently": "^7.1.0", + "command-line-args": "^5.2.1", + "concurrently": "^7.2.1", "css-loader": "^6.7.1", "deepdash": "^5.3.9", - "dompurify": "^2.3.6", + "dompurify": "^2.3.8", "electron": "^14.2.9", "electron-builder": "^23.0.3", "electron-notarize": "^0.3.0", "esbuild": "^0.14.38", - "esbuild-loader": "^2.18.0", - "eslint": "^8.14.0", + "esbuild-loader": "^2.19.0", + "eslint": "^8.16.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-react": "^7.29.4", - "eslint-plugin-react-hooks": "^4.4.0", + "eslint-plugin-react": "^7.30.0", + "eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-unused-imports": "^2.0.0", "flex.box": "^3.4.4", - "fork-ts-checker-webpack-plugin": "^6.5.0", + "fork-ts-checker-webpack-plugin": "^6.5.2", "gunzip-maybe": "^1.4.2", - "hoist-non-react-statics": "^3.3.2", "html-webpack-plugin": "^5.5.0", "identity-obj-proxy": "^3.0.0", "ignore-loader": "^0.1.2", @@ -374,37 +383,36 @@ "node-gyp": "7.1.2", "node-loader": "^2.0.0", "nodemon": "^2.0.16", - "playwright": "^1.20.2", - "postcss": "^8.4.12", + "playwright": "^1.22.2", + "postcss": "^8.4.14", "postcss-loader": "^6.2.1", "randomcolor": "^0.6.2", "react-beautiful-dnd": "^13.1.0", - "react-refresh": "^0.12.0", - "react-refresh-typescript": "^2.0.4", - "react-router-dom": "^5.3.1", - "react-select": "^5.3.0", + "react-refresh": "^0.13.0", + "react-refresh-typescript": "^2.0.5", + "react-router-dom": "^5.3.3", + "react-select": "^5.3.2", "react-select-event": "^5.5.0", - "react-table": "^7.7.0", - "react-window": "^1.8.6", - "sass": "^1.51.0", + "react-table": "^7.8.0", + "react-window": "^1.8.7", + "sass": "^1.52.2", "sass-loader": "^12.6.0", - "sharp": "^0.30.4", + "sharp": "^0.30.6", "style-loader": "^3.3.1", "tailwindcss": "^3.0.23", "tar-stream": "^2.2.0", "ts-jest": "26.5.6", "ts-loader": "^9.2.8", "ts-node": "^10.7.0", - "type-fest": "^2.12.2", + "type-fest": "^2.13.0", "typed-emitter": "^1.4.0", - "typedoc": "0.22.15", + "typedoc": "0.22.17", "typedoc-plugin-markdown": "^3.11.12", - "typeface-roboto": "^1.1.13", "typescript": "^4.5.5", "typescript-plugin-css-modules": "^3.4.0", - "webpack": "^5.72.0", + "webpack": "^5.73.0", "webpack-cli": "^4.9.2", - "webpack-dev-server": "^4.8.1", + "webpack-dev-server": "^4.9.1", "webpack-node-externals": "^3.0.0", "xterm": "^4.18.0", "xterm-addon-fit": "^0.5.0" diff --git a/scripts/clear-release-pr.mjs b/scripts/clear-release-pr.mjs new file mode 100755 index 0000000000..4b55384e61 --- /dev/null +++ b/scripts/clear-release-pr.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// This script creates a release PR +import { execSync, exec, spawn } from "child_process"; +import commandLineArgs from "command-line-args"; +import fse from "fs-extra"; +import { basename } from "path"; +import semver from "semver"; +import { promisify } from "util"; + +const { + SemVer, + valid: semverValid, + rcompare: semverRcompare, + lte: semverLte, +} = semver; +const { readJsonSync } = fse; +const execP = promisify(exec); + +const options = commandLineArgs([ + { + name: "type", + defaultOption: true, + }, + { + name: "preid", + }, +]); + +const validReleaseValues = [ + "major", + "minor", + "patch", +]; +const validPrereleaseValues = [ + "premajor", + "preminor", + "prepatch", + "prerelease", +]; +const validPreidValues = [ + "alpha", + "beta", +]; + +const errorMessages = { + noReleaseType: `No release type provided. Valid options are: ${[...validReleaseValues, ...validPrereleaseValues].join(", ")}`, + invalidRelease: (invalid) => `Invalid release type was provided (value was "${invalid}"). Valid options are: ${[...validReleaseValues, ...validPrereleaseValues].join(", ")}`, + noPreid: `No preid was provided. Use '--preid' to specify. Valid options are: ${validPreidValues.join(", ")}`, + invalidPreid: (invalid) => `Invalid preid was provided (value was "${invalid}"). Valid options are: ${validPreidValues.join(", ")}`, + wrongCwd: "It looks like you are running this script from the 'scripts' directory. This script assumes it is run from the root of the git repo", +}; + +if (!options.type) { + console.error(errorMessages.noReleaseType); + process.exit(1); +} + +if (validReleaseValues.includes(options.type)) { + // do nothing, is valid +} else if (validPrereleaseValues.includes(options.type)) { + if (!options.preid) { + console.error(errorMessages.noPreid); + process.exit(1); + } + + if (!validPreidValues.includes(options.preid)) { + console.error(errorMessages.invalidPreid(options.preid)); + process.exit(1); + } +} else { + console.error(errorMessages.invalidRelease(options.type)); + process.exit(1); +} + +if (basename(process.cwd()) === "scripts") { + console.error(errorMessages.wrongCwd); +} + + +const currentVersion = new SemVer(readJsonSync("./package.json").version); +const currentVersionMilestone = `${currentVersion.major}.${currentVersion.minor}.${currentVersion.patch}`; + +console.log(`current version: ${currentVersion.format()}`); +console.log("fetching tags..."); +execSync("git fetch --tags --force"); + +const actualTags = execSync("git tag --list", { encoding: "utf-8" }).split(/\r?\n/).map(line => line.trim()); +const [previousReleasedVersion] = actualTags + .map(semverValid) + .filter(Boolean) + .sort(semverRcompare) + .filter(version => semverLte(version, currentVersion)); + +const npmVersionArgs = [ + "npm", + "version", + options.type, +]; + +if (options.preid) { + npmVersionArgs.push(`--preid=${options.preid}`); +} + +npmVersionArgs.push("--git-tag-version false"); + +execSync(npmVersionArgs.join(" "), { stdio: "ignore" }); + +const newVersion = new SemVer(readJsonSync("./package.json").version); + +const getMergedPrsArgs = [ + "gh", + "pr", + "list", + "--limit=500", // Should be big enough, if not we need to release more often ;) + "--state=merged", + "--base=master", + "--json mergeCommit,title,author,labels,number,milestone", +]; + +console.log("retreiving last 500 PRs to create release PR body..."); +const mergedPrs = JSON.parse(execSync(getMergedPrsArgs.join(" "), { encoding: "utf-8" })); +const milestoneRelevantPrs = mergedPrs.filter(pr => pr.milestone && pr.milestone.title === currentVersionMilestone); +const relaventPrsQuery = await Promise.all( + milestoneRelevantPrs.map(async pr => ({ + pr, + stdout: (await execP(`git tag v${previousReleasedVersion} --no-contains ${pr.mergeCommit.oid}`)).stdout, + })), +); +const relaventPrs = relaventPrsQuery + .filter(query => query.stdout) + .map(query => query.pr); + +const enhancementPrLabelName = "enhancement"; +const bugfixPrLabelName = "bug"; + +const enhancementPrs = relaventPrs.filter(pr => pr.labels.some(label => label.name === enhancementPrLabelName)); +const bugfixPrs = relaventPrs.filter(pr => pr.labels.some(label => label.name === bugfixPrLabelName)); +const maintenencePrs = relaventPrs.filter(pr => pr.labels.every(label => label.name !== bugfixPrLabelName && label.name !== enhancementPrLabelName)); + +console.log("Found:"); +console.log(`${enhancementPrs.length} enhancement PRs`); +console.log(`${bugfixPrs.length} bug fix PRs`); +console.log(`${maintenencePrs.length} maintenence PRs`); + +const prBodyLines = [ + `## Changes since ${previousReleasedVersion}`, + "", +]; + +if (enhancementPrs.length > 0) { + prBodyLines.push( + "## 🚀 Features", + "", + ...enhancementPrs.map(pr => `- ${pr.title} (**#${pr.number}**) https://github.com/${pr.author.login}`), + "", + ); +} + +if (bugfixPrs.length > 0) { + prBodyLines.push( + "## 🐛 Bug Fixes", + "", + ...bugfixPrs.map(pr => `- ${pr.title} (**#${pr.number}**) https://github.com/${pr.author.login}`), + "", + ); +} + +if (maintenencePrs.length > 0) { + prBodyLines.push( + "## 🧰 Maintenance", + "", + ...maintenencePrs.map(pr => `- ${pr.title} (**#${pr.number}**) https://github.com/${pr.author.login}`), + "", + ); +} + +const prBody = prBodyLines.join("\n"); +const prBase = newVersion.patch === 0 + ? "master" + : `release/v${newVersion.major}.${newVersion.minor}`; +const createPrArgs = [ + "pr", + "create", + "--base", prBase, + "--title", `release ${newVersion.format()}`, + "--label", "skip-changelog", + "--body-file", "-", +]; + +const createPrProcess = spawn("gh", createPrArgs, { stdio: "pipe" }); +let result = ""; + +createPrProcess.stdout.on("data", (chunk) => result += chunk); + +createPrProcess.stdin.write(prBody); +createPrProcess.stdin.end(); + +await new Promise((resolve) => { + createPrProcess.on("close", () => { + createPrProcess.stdout.removeAllListeners(); + resolve(); + }); +}); + +console.log(result); diff --git a/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap b/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap index 3b43a51f66..80b0028469 100644 --- a/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap +++ b/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap @@ -1,11 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`extension special characters in page registrations renders 1`] = `
`; +exports[`extension special characters in page registrations renders 1`] = ` +
+
+
+`; exports[`extension special characters in page registrations when navigating to route with ID having special characters renders 1`] = `
Some page
+
`; diff --git a/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap b/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap index edab04b903..c96763fe6e 100644 --- a/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap +++ b/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap @@ -1,12 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`navigate to extension page renders 1`] = `
`; +exports[`navigate to extension page renders 1`] = ` +
+
+
+`; exports[`navigate to extension page when extension navigates to child route renders 1`] = `
Child page
+
`; @@ -31,6 +40,9 @@ exports[`navigate to extension page when extension navigates to route with param Some button
+
`; @@ -55,6 +67,9 @@ exports[`navigate to extension page when extension navigates to route without pa Some button
+
`; @@ -79,5 +94,8 @@ exports[`navigate to extension page when extension navigates to route without pa Some button
+
`; diff --git a/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap b/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap index 90ff615b2b..10e9eb2d39 100644 --- a/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap +++ b/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap @@ -8,6 +8,9 @@ exports[`navigating between routes given route with optional path parameters whe "someOtherParameter": "some-other-value" } +
`; @@ -16,5 +19,8 @@ exports[`navigating between routes given route without path parameters when navi
Some component
+
`; diff --git a/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.ts.snap b/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap similarity index 88% rename from src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.ts.snap rename to src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap index 4c1203829a..0fd00133aa 100644 --- a/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.ts.snap +++ b/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`add-cluster - navigation using application menu renders 1`] = `
`; +exports[`add-cluster - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`add-cluster - navigation using application menu when navigating to add cluster using application menu renders 1`] = `
@@ -19,7 +25,7 @@ exports[`add-cluster - navigation using application menu when navigating to add Add Clusters from Kubeconfig

- Clusters added here are + Clusters added here are not @@ -27,16 +33,14 @@ exports[`add-cluster - navigation using application menu when navigating to add ~/.kube/config - file. - + file. - Read more about adding clusters + Read more about adding clusters. - .

+
`; diff --git a/src/behaviours/add-cluster/navigation-using-application-menu.test.ts b/src/behaviours/add-cluster/navigation-using-application-menu.test.tsx similarity index 72% rename from src/behaviours/add-cluster/navigation-using-application-menu.test.ts rename to src/behaviours/add-cluster/navigation-using-application-menu.test.tsx index cc7749a7e9..bb68918c1a 100644 --- a/src/behaviours/add-cluster/navigation-using-application-menu.test.ts +++ b/src/behaviours/add-cluster/navigation-using-application-menu.test.tsx @@ -6,20 +6,27 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; +import React from "react"; // TODO: Make components free of side effects by making them deterministic -jest.mock("../../renderer/components/tooltip"); -jest.mock("../../renderer/components/monaco-editor/monaco-editor"); +jest.mock("../../renderer/components/tooltip/tooltip", () => ({ + Tooltip: () => null, +})); + +jest.mock("../../renderer/components/tooltip/withTooltip", () => ({ + withTooltip: (Target: any) => ({ tooltip, tooltipOverrideDisabled, ...props }: any) => , +})); + +jest.mock("../../renderer/components/monaco-editor/monaco-editor", () => ({ + MonacoEditor: () => null, +})); describe("add-cluster - navigation using application menu", () => { let applicationBuilder: ApplicationBuilder; let rendered: RenderResult; beforeEach(async () => { - applicationBuilder = getApplicationBuilder().beforeSetups(({ mainDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - }); + applicationBuilder = getApplicationBuilder(); rendered = await applicationBuilder.render(); }); @@ -35,8 +42,8 @@ describe("add-cluster - navigation using application menu", () => { }); describe("when navigating to add cluster using application menu", () => { - beforeEach(() => { - applicationBuilder.applicationMenu.click("file.add-cluster"); + beforeEach(async () => { + await applicationBuilder.applicationMenu.click("file.add-cluster"); }); it("renders", () => { diff --git a/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap b/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap new file mode 100644 index 0000000000..32e6cb1cb1 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap @@ -0,0 +1,536 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`installing update using tray when started renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when new update is discovered renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download for version some-version started... +
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when new update is discovered when download fails renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download for version some-version started... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download of update failed +
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when new update is discovered when download succeeds renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download for version some-version started... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+
+ + Update Available + +

+ Version some-version of Lens IDE is available and ready to be installed. Would you like to update now? + +Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating. +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when no new update is discovered renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ No new updates available +
+
+ + + close + + +
+
+
+
+ +`; diff --git a/src/behaviours/application-update/__snapshots__/installing-update.test.ts.snap b/src/behaviours/application-update/__snapshots__/installing-update.test.ts.snap new file mode 100644 index 0000000000..7025289254 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/installing-update.test.ts.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`installing update when started renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download fails renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download succeeds renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download succeeds when user answers not to install the update renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download succeeds when user answers to install the update renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when no new update is discovered renders 1`] = ` + +
+
+
+ +`; diff --git a/src/behaviours/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap b/src/behaviours/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap new file mode 100644 index 0000000000..84fa35ae04 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`periodical checking of updates given updater is enabled and configuration exists, when started renders 1`] = ` + +
+
+
+ +`; diff --git a/src/behaviours/application-update/__snapshots__/selection-of-update-stability.test.ts.snap b/src/behaviours/application-update/__snapshots__/selection-of-update-stability.test.ts.snap new file mode 100644 index 0000000000..dc96c447b0 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/selection-of-update-stability.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`selection of update stability when started renders 1`] = ` + +
+
+
+ +`; diff --git a/src/behaviours/application-update/downgrading-version-update.test.ts b/src/behaviours/application-update/downgrading-version-update.test.ts new file mode 100644 index 0000000000..e8e5635fb3 --- /dev/null +++ b/src/behaviours/application-update/downgrading-version-update.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; +import selectedUpdateChannelInjectable from "../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; +import { updateChannels } from "../../common/application-update/update-channels"; + +describe("downgrading version update", () => { + let applicationBuilder: ApplicationBuilder; + let checkForPlatformUpdatesMock: AsyncFnMock; + let mainDi: DiContainer; + + beforeEach(() => { + jest.useFakeTimers(); + + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + checkForPlatformUpdatesMock = asyncFn(); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + + mainDi = applicationBuilder.dis.mainDi; + }); + + [ + { + updateChannel: updateChannels.latest, + appVersion: "4.0.0-beta", + downgradeIsAllowed: true, + }, + { + updateChannel: updateChannels.beta, + appVersion: "4.0.0-beta", + downgradeIsAllowed: false, + }, + { + updateChannel: updateChannels.beta, + appVersion: "4.0.0-beta.1", + downgradeIsAllowed: false, + }, + { + updateChannel: updateChannels.alpha, + appVersion: "4.0.0-beta", + downgradeIsAllowed: true, + }, + { + updateChannel: updateChannels.alpha, + appVersion: "4.0.0-alpha", + downgradeIsAllowed: false, + }, + ].forEach(({ appVersion, updateChannel, downgradeIsAllowed }) => { + it(`given application version "${appVersion}" and update channel "${updateChannel.id}", when checking for updates, can${downgradeIsAllowed ? "": "not"} downgrade`, async () => { + mainDi.override(appVersionInjectable, () => appVersion); + + await applicationBuilder.render(); + + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue(updateChannel.id); + + const processCheckingForUpdates = mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(expect.any(Object), { allowDowngrade: downgradeIsAllowed }); + }); + }); +}); diff --git a/src/behaviours/application-update/installing-update-using-tray.test.ts b/src/behaviours/application-update/installing-update-using-tray.test.ts new file mode 100644 index 0000000000..f6570bb8fa --- /dev/null +++ b/src/behaviours/application-update/installing-update-using-tray.test.ts @@ -0,0 +1,235 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { DownloadPlatformUpdate } from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import showApplicationWindowInjectable from "../../main/start-main-application/lens-window/show-application-window.injectable"; +import progressOfUpdateDownloadInjectable from "../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; + +describe("installing update using tray", () => { + let applicationBuilder: ApplicationBuilder; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let showApplicationWindowMock: jest.Mock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + checkForPlatformUpdatesMock = asyncFn(); + downloadPlatformUpdateMock = asyncFn(); + showApplicationWindowMock = jest.fn(); + + mainDi.override(showApplicationWindowInjectable, () => showApplicationWindowMock); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override( + downloadPlatformUpdateInjectable, + () => downloadPlatformUpdateMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("user cannot install update yet", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + describe("when user checks for updates using tray", () => { + let processCheckingForUpdatesPromise: Promise; + + beforeEach(async () => { + processCheckingForUpdatesPromise = + applicationBuilder.tray.click("check-for-updates"); + }); + + it("does not show application window yet", () => { + expect(showApplicationWindowMock).not.toHaveBeenCalled(); + }); + + it("user cannot check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(false); + }); + + it("name of tray item for checking updates indicates that checking is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Checking for updates..."); + }); + + it("user cannot install update yet", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when no new update is discovered", () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + + await processCheckingForUpdatesPromise; + }); + + it("shows application window", () => { + expect(showApplicationWindowMock).toHaveBeenCalled(); + }); + + it("user cannot install update", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + it("user can check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(true); + }); + + it("name of tray item for checking updates no longer indicates that checking is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Check for updates"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when new update is discovered", () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + await processCheckingForUpdatesPromise; + }); + + it("shows application window", () => { + expect(showApplicationWindowMock).toHaveBeenCalled(); + }); + + it("user cannot check for updates again yet", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(false); + }); + + it("name of tray item for checking updates indicates that downloading is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Downloading update some-version (0%)..."); + }); + + it("when download progresses with decimals, percentage increases as integers", () => { + const progressOfUpdateDownload = applicationBuilder.dis.mainDi.inject( + progressOfUpdateDownloadInjectable, + ); + + progressOfUpdateDownload.set({ percentage: 42.424242 }); + + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Downloading update some-version (42%)..."); + }); + + it("user still cannot install update", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when download fails", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: false }); + }); + + it("user cannot install update", () => { + expect( + applicationBuilder.tray.get("install-update"), + ).toBeUndefined(); + }); + + it("user can check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(true); + }); + + it("name of tray item for checking updates no longer indicates that downloading is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Check for updates"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when download succeeds", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: true }); + }); + + it("user can install update", () => { + expect( + applicationBuilder.tray.get("install-update")?.label?.get(), + ).toBe("Install update some-version"); + }); + + it("user can check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(true); + }); + + it("name of tray item for checking updates no longer indicates that downloading is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Check for updates"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + }); + }); + }); +}); diff --git a/src/behaviours/application-update/installing-update.test.ts b/src/behaviours/application-update/installing-update.test.ts new file mode 100644 index 0000000000..3fec5f6d27 --- /dev/null +++ b/src/behaviours/application-update/installing-update.test.ts @@ -0,0 +1,225 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import quitAndInstallUpdateInjectable from "../../main/electron-app/features/quit-and-install-update.injectable"; +import type { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { DownloadPlatformUpdate } from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import setUpdateOnQuitInjectable from "../../main/electron-app/features/set-update-on-quit.injectable"; +import type { AskBoolean } from "../../main/ask-boolean/ask-boolean.injectable"; +import askBooleanInjectable from "../../main/ask-boolean/ask-boolean.injectable"; +import showInfoNotificationInjectable from "../../renderer/components/notifications/show-info-notification.injectable"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; + +describe("installing update", () => { + let applicationBuilder: ApplicationBuilder; + let quitAndInstallUpdateMock: jest.Mock; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let setUpdateOnQuitMock: jest.Mock; + let showInfoNotificationMock: jest.Mock; + let askBooleanMock: AsyncFnMock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => { + quitAndInstallUpdateMock = jest.fn(); + checkForPlatformUpdatesMock = asyncFn(); + downloadPlatformUpdateMock = asyncFn(); + setUpdateOnQuitMock = jest.fn(); + showInfoNotificationMock = jest.fn(() => () => {}); + askBooleanMock = asyncFn(); + + rendererDi.override(showInfoNotificationInjectable, () => showInfoNotificationMock); + + mainDi.override(askBooleanInjectable, () => askBooleanMock); + mainDi.override(setUpdateOnQuitInjectable, () => setUpdateOnQuitMock); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override( + downloadPlatformUpdateInjectable, + () => downloadPlatformUpdateMock, + ); + + mainDi.override( + quitAndInstallUpdateInjectable, + () => quitAndInstallUpdateMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + let processCheckingForUpdates: () => Promise; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + + processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when user checks for updates", () => { + let processCheckingForUpdatesPromise: Promise; + + beforeEach(async () => { + processCheckingForUpdatesPromise = processCheckingForUpdates(); + }); + + it("checks for updates", () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + expect.any(Object), + { allowDowngrade: true }, + ); + }); + + it("notifies the user that checking for updates is happening", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("Checking for updates..."); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when no new update is discovered", () => { + beforeEach(async () => { + showInfoNotificationMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + + await processCheckingForUpdatesPromise; + }); + + it("notifies the user", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("No new updates available"); + }); + + it("does not start downloading update", () => { + expect(downloadPlatformUpdateMock).not.toHaveBeenCalled(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when new update is discovered", () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + await processCheckingForUpdatesPromise; + }); + + it("starts downloading the update", () => { + expect(downloadPlatformUpdateMock).toHaveBeenCalled(); + }); + + it("notifies the user that download is happening", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("Download for version some-version started..."); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when download fails", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: false }); + }); + + it("does not quit and install update yet", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + + it("notifies the user about failed download", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("Download of update failed"); + }); + + it("does not ask user to install update", () => { + expect(askBooleanMock).not.toHaveBeenCalled(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when download succeeds", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: true }); + }); + + it("does not quit and install update yet", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("asks user to install update immediately", () => { + expect(askBooleanMock).toHaveBeenCalledWith({ + title: "Update Available", + question: + "Version some-version of Lens IDE is available and ready to be installed. Would you like to update now?\n\n" + + "Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating.", + }); + }); + + describe("when user answers to install the update", () => { + beforeEach(async () => { + await askBooleanMock.resolve(true); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("quits application and installs the update", () => { + expect(quitAndInstallUpdateMock).toHaveBeenCalled(); + }); + }); + + describe("when user answers not to install the update", () => { + beforeEach(async () => { + await askBooleanMock.resolve(false); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not quit application and install the update", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/behaviours/application-update/periodical-checking-of-updates.test.ts b/src/behaviours/application-update/periodical-checking-of-updates.test.ts new file mode 100644 index 0000000000..e81c002e34 --- /dev/null +++ b/src/behaviours/application-update/periodical-checking-of-updates.test.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; +import periodicalCheckForUpdatesInjectable from "../../main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable"; + +const ENOUGH_TIME = 1000 * 60 * 60 * 2; + +describe("periodical checking of updates", () => { + let applicationBuilder: ApplicationBuilder; + let processCheckingForUpdatesMock: AsyncFnMock<() => Promise>; + + beforeEach(() => { + jest.useFakeTimers(); + + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.unoverride(periodicalCheckForUpdatesInjectable); + mainDi.permitSideEffects(periodicalCheckForUpdatesInjectable); + + processCheckingForUpdatesMock = asyncFn(); + + mainDi.override( + processCheckingForUpdatesInjectable, + () => processCheckingForUpdatesMock, + ); + }); + }); + + describe("given updater is enabled and configuration exists, when started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("checks for updates", () => { + expect(processCheckingForUpdatesMock).toHaveBeenCalled(); + }); + + it("when just not enough time passes, does not check for updates again automatically yet", () => { + processCheckingForUpdatesMock.mockClear(); + + jest.advanceTimersByTime(ENOUGH_TIME - 1); + + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + + it("when just enough time passes, checks for updates again automatically", () => { + processCheckingForUpdatesMock.mockClear(); + + jest.advanceTimersByTime(ENOUGH_TIME); + + expect(processCheckingForUpdatesMock).toHaveBeenCalled(); + }); + }); + + describe("given updater is enabled but no configuration exist, when started", () => { + beforeEach(async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => false); + }); + + await applicationBuilder.render(); + }); + + it("does not check for updates", () => { + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + + it("when time passes, never checks for updates", () => { + jest.runOnlyPendingTimers(); + + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + }); + + describe("given updater is not enabled but and configuration exist, when started", () => { + beforeEach(async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(electronUpdaterIsActiveInjectable, () => false); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + + await applicationBuilder.render(); + }); + + it("does not check for updates", () => { + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + + it("when time passes, never checks for updates", () => { + jest.runOnlyPendingTimers(); + + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/behaviours/application-update/selection-of-update-stability.test.ts b/src/behaviours/application-update/selection-of-update-stability.test.ts new file mode 100644 index 0000000000..1792fcd484 --- /dev/null +++ b/src/behaviours/application-update/selection-of-update-stability.test.ts @@ -0,0 +1,331 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import quitAndInstallUpdateInjectable from "../../main/electron-app/features/quit-and-install-update.injectable"; +import type { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { UpdateChannel, UpdateChannelId } from "../../common/application-update/update-channels"; +import { updateChannels } from "../../common/application-update/update-channels"; +import type { DownloadPlatformUpdate } from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import selectedUpdateChannelInjectable from "../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import type { IComputedValue } from "mobx"; +import setUpdateOnQuitInjectable from "../../main/electron-app/features/set-update-on-quit.injectable"; +import type { AskBoolean } from "../../main/ask-boolean/ask-boolean.injectable"; +import askBooleanInjectable from "../../main/ask-boolean/ask-boolean.injectable"; +import showInfoNotificationInjectable from "../../renderer/components/notifications/show-info-notification.injectable"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; +import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; + +describe("selection of update stability", () => { + let applicationBuilder: ApplicationBuilder; + let quitAndInstallUpdateMock: jest.Mock; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let setUpdateOnQuitMock: jest.Mock; + let showInfoNotificationMock: jest.Mock; + let askBooleanMock: AsyncFnMock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => { + quitAndInstallUpdateMock = jest.fn(); + checkForPlatformUpdatesMock = asyncFn(); + downloadPlatformUpdateMock = asyncFn(); + setUpdateOnQuitMock = jest.fn(); + showInfoNotificationMock = jest.fn(() => () => {}); + askBooleanMock = asyncFn(); + + rendererDi.override(showInfoNotificationInjectable, () => showInfoNotificationMock); + + mainDi.override(askBooleanInjectable, () => askBooleanMock); + mainDi.override(setUpdateOnQuitInjectable, () => setUpdateOnQuitMock); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override( + downloadPlatformUpdateInjectable, + () => downloadPlatformUpdateMock, + ); + + mainDi.override( + quitAndInstallUpdateInjectable, + () => quitAndInstallUpdateMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + let processCheckingForUpdates: () => Promise; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + + processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe('given update channel "alpha" is selected, when checking for updates', () => { + let selectedUpdateChannel: { + value: IComputedValue; + setValue: (channelId: UpdateChannelId) => void; + }; + + beforeEach(() => { + selectedUpdateChannel = applicationBuilder.dis.mainDi.inject( + selectedUpdateChannelInjectable, + ); + + selectedUpdateChannel.setValue(updateChannels.alpha.id); + + processCheckingForUpdates(); + }); + + it('checks updates from update channel "alpha"', () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.alpha, + { allowDowngrade: true }, + ); + }); + + it("when update is discovered, does not check update from other update channels", async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + expect(checkForPlatformUpdatesMock).not.toHaveBeenCalled(); + }); + + describe("when no update is discovered", () => { + beforeEach(async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + }); + + it('checks updates from update channel "beta"', () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.beta, + { allowDowngrade: true }, + ); + }); + + it("when update is discovered, does not check update from other update channels", async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + expect(checkForPlatformUpdatesMock).not.toHaveBeenCalled(); + }); + + describe("when no update is discovered again", () => { + beforeEach(async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + }); + + it('finally checks updates from update channel "latest"', () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.latest, + { allowDowngrade: true }, + ); + }); + + it("when update is discovered, does not check update from other update channels", async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + expect(checkForPlatformUpdatesMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('given update channel "beta" is selected', () => { + let selectedUpdateChannel: { + value: IComputedValue; + setValue: (channelId: UpdateChannelId) => void; + }; + + beforeEach(() => { + selectedUpdateChannel = applicationBuilder.dis.mainDi.inject( + selectedUpdateChannelInjectable, + ); + + selectedUpdateChannel.setValue(updateChannels.beta.id); + }); + + describe("when checking for updates", () => { + beforeEach(() => { + processCheckingForUpdates(); + }); + + describe('when update from "beta" channel is discovered', () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-beta-version", + }); + }); + + describe("when update is downloaded", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: true }); + }); + + it("when user would close the application, installs the update", () => { + expect(setUpdateOnQuitMock).toHaveBeenLastCalledWith(true); + }); + + it('given user changes update channel to "latest", when user would close the application, does not install the update for not being stable enough', () => { + selectedUpdateChannel.setValue(updateChannels.latest.id); + + expect(setUpdateOnQuitMock).toHaveBeenLastCalledWith(false); + }); + + it('given user changes update channel to "alpha", when user would close the application, installs the update for being stable enough', () => { + selectedUpdateChannel.setValue(updateChannels.alpha.id); + + expect(setUpdateOnQuitMock).toHaveBeenLastCalledWith(false); + }); + }); + }); + }); + }); + }); + + it("given valid update channel selection is stored, when checking for updates, checks for updates from the update channel", async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + // TODO: Switch to more natural way of setting initial value + // TODO: UserStore is currently responsible for getting and setting initial value + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue(updateChannels.beta.id); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.beta, expect.any(Object)); + }); + + it("given invalid update channel selection is stored, when checking for updates, checks for updates from the update channel", async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + // TODO: Switch to more natural way of setting initial value + // TODO: UserStore is currently responsible for getting and setting initial value + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue("something-invalid" as UpdateChannelId); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.latest, expect.any(Object)); + }); + + it('given no update channel selection is stored and currently using stable release, when user checks for updates, checks for updates from "latest" update channel by default', async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0"); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.latest, + { allowDowngrade: true }, + ); + }); + + it('given no update channel selection is stored and currently using alpha release, when checking for updates, checks for updates from "alpha" channel', async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0-alpha"); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.alpha, expect.any(Object)); + }); + + it('given no update channel selection is stored and currently using beta release, when checking for updates, checks for updates from "beta" channel', async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0-beta"); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.beta, expect.any(Object)); + }); + + it("given update channel selection is stored and currently using prerelease, when checking for updates, checks for updates from stored channel", async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0-alpha"); + + // TODO: Switch to more natural way of setting initial value + // TODO: UserStore is currently responsible for getting and setting initial value + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue(updateChannels.beta.id); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.beta, expect.any(Object)); + }); +}); diff --git a/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap b/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap index 092337ec82..9af01f0969 100644 --- a/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap @@ -328,6 +328,9 @@ exports[`cluster - order of sidebar items when rendered renders 1`] = `
+
`; @@ -723,5 +726,8 @@ exports[`cluster - order of sidebar items when rendered when parent is expanded
+
`; diff --git a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap index ad1f7a8d4c..06d1a1e210 100644 --- a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap @@ -293,6 +293,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -589,6 +592,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -909,6 +915,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -1234,6 +1243,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -1534,6 +1546,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -1854,6 +1869,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -2150,5 +2168,8 @@ exports[`cluster - sidebar and tab navigation for core given core registrations +
`; diff --git a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap index 808ea1128f..19cb615cce 100644 --- a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap @@ -261,14 +261,14 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit
+
`; diff --git a/src/behaviours/extensions/navigation-using-application-menu.test.ts b/src/behaviours/extensions/navigation-using-application-menu.test.ts index ef714a75d3..5d05ec31c2 100644 --- a/src/behaviours/extensions/navigation-using-application-menu.test.ts +++ b/src/behaviours/extensions/navigation-using-application-menu.test.ts @@ -6,12 +6,7 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; -import extensionsStoreInjectable from "../../extensions/extensions-store/extensions-store.injectable"; -import type { ExtensionsStore } from "../../extensions/extensions-store/extensions-store"; -import fileSystemProvisionerStoreInjectable from "../../extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable"; -import type { FileSystemProvisionerStore } from "../../extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store"; -import focusWindowInjectable from "../../renderer/ipc-channel-listeners/focus-window.injectable"; +import focusWindowInjectable from "../../renderer/navigation/focus-window.injectable"; // TODO: Make components free of side effects by making them deterministic jest.mock("../../renderer/components/input/input"); @@ -22,11 +17,7 @@ describe("extensions - navigation using application menu", () => { let focusWindowMock: jest.Mock; beforeEach(async () => { - applicationBuilder = getApplicationBuilder().beforeSetups(({ mainDi, rendererDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - rendererDi.override(extensionsStoreInjectable, () => ({}) as unknown as ExtensionsStore); - rendererDi.override(fileSystemProvisionerStoreInjectable, () => ({}) as unknown as FileSystemProvisionerStore); - + applicationBuilder = getApplicationBuilder().beforeApplicationStart(({ rendererDi }) => { focusWindowMock = jest.fn(); rendererDi.override(focusWindowInjectable, () => focusWindowMock); @@ -46,8 +37,8 @@ describe("extensions - navigation using application menu", () => { }); describe("when navigating to extensions using application menu", () => { - beforeEach(() => { - applicationBuilder.applicationMenu.click("root.extensions"); + beforeEach(async () => { + await applicationBuilder.applicationMenu.click("root.extensions"); }); it("focuses the window", () => { diff --git a/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap b/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap new file mode 100644 index 0000000000..e323205008 --- /dev/null +++ b/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap @@ -0,0 +1,461 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`helm-charts - navigation to Helm charts when navigating to Helm charts renders 1`] = ` +
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Name +
+ + + arrow_drop_down + + +
+
+
+ Description +
+
+
+
+ Version +
+
+
+
+ App Version +
+
+
+
+ Repository +
+ + + arrow_drop_down + + +
+ +
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/behaviours/helm-charts/navigation-to-helm-charts.test.ts b/src/behaviours/helm-charts/navigation-to-helm-charts.test.ts new file mode 100644 index 0000000000..c539db173a --- /dev/null +++ b/src/behaviours/helm-charts/navigation-to-helm-charts.test.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RenderResult } from "@testing-library/react"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; + +describe("helm-charts - navigation to Helm charts", () => { + let applicationBuilder: ApplicationBuilder; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + }); + + describe("when navigating to Helm charts", () => { + let rendered: RenderResult; + + beforeEach(async () => { + applicationBuilder.setEnvironmentToClusterFrame(); + + rendered = await applicationBuilder.render(); + + applicationBuilder.helmCharts.navigate(); + }); + + it("renders", () => { + expect(rendered.container).toMatchSnapshot(); + }); + + it("shows page for Helm charts", () => { + const page = rendered.getByTestId("page-for-helm-charts"); + + expect(page).not.toBeNull(); + }); + }); +}); diff --git a/src/behaviours/navigate-to-extension-page.test.tsx b/src/behaviours/navigate-to-extension-page.test.tsx index 915d3bab2b..35c47b9076 100644 --- a/src/behaviours/navigate-to-extension-page.test.tsx +++ b/src/behaviours/navigate-to-extension-page.test.tsx @@ -2,8 +2,8 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { TestExtension } from "../renderer/components/test-utils/get-renderer-extension-fake"; -import { getRendererExtensionFake } from "../renderer/components/test-utils/get-renderer-extension-fake"; +import type { FakeExtensionData, TestExtension } from "../renderer/components/test-utils/get-renderer-extension-fake"; +import { getRendererExtensionFakeFor } from "../renderer/components/test-utils/get-renderer-extension-fake"; import React from "react"; import type { RenderResult } from "@testing-library/react"; import { fireEvent } from "@testing-library/react"; @@ -11,7 +11,6 @@ import isEmpty from "lodash/isEmpty"; import queryParametersInjectable from "../renderer/routes/query-parameters.injectable"; import currentPathInjectable from "../renderer/routes/current-path.injectable"; import type { IComputedValue } from "mobx"; -import type { LensRendererExtension } from "../extensions/lens-renderer-extension"; import { getApplicationBuilder } from "../renderer/components/test-utils/get-application-builder"; describe("navigate to extension page", () => { @@ -22,6 +21,7 @@ describe("navigate to extension page", () => { beforeEach(async () => { const applicationBuilder = getApplicationBuilder(); + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); testExtension = getRendererExtensionFake( extensionWithPagesHavingParameters, @@ -51,7 +51,7 @@ describe("navigate to extension page", () => { }); it("URL is correct", () => { - expect(currentPath.get()).toBe("/extension/some-extension-id"); + expect(currentPath.get()).toBe("/extension/some-extension-name"); }); it("query parameters is empty", () => { @@ -70,7 +70,7 @@ describe("navigate to extension page", () => { }); it("URL is correct", () => { - expect(currentPath.get()).toBe("/extension/some-extension-id"); + expect(currentPath.get()).toBe("/extension/some-extension-name"); }); it("knows query parameters", () => { @@ -98,7 +98,7 @@ describe("navigate to extension page", () => { }); it("URL is correct", () => { - expect(currentPath.get()).toBe("/extension/some-extension-id"); + expect(currentPath.get()).toBe("/extension/some-extension-name"); }); it("knows query parameters", () => { @@ -120,14 +120,14 @@ describe("navigate to extension page", () => { }); it("URL is correct", () => { - expect(currentPath.get()).toBe("/extension/some-extension-id/some-child-page-id"); + expect(currentPath.get()).toBe("/extension/some-extension-name/some-child-page-id"); }); }); }); -const extensionWithPagesHavingParameters: Partial = { +const extensionWithPagesHavingParameters: FakeExtensionData = { id: "some-extension-id", - + name: "some-extension-name", globalPages: [ { components: { @@ -159,20 +159,14 @@ const extensionWithPagesHavingParameters: Partial = { params: { someStringParameter: "some-string-value", - someNumberParameter: { defaultValue: 42, - stringify: (value) => value.toString(), - parse: (value) => (value ? Number(value) : undefined), }, - someArrayParameter: { defaultValue: ["some-array-value", "some-other-array-value"], - stringify: (value) => value.join(","), - parse: (value: string[]) => (!isEmpty(value) ? value : undefined), }, }, diff --git a/src/behaviours/navigating-between-routes.test.tsx b/src/behaviours/navigating-between-routes.test.tsx index 4cb55cd290..4def9b383f 100644 --- a/src/behaviours/navigating-between-routes.test.tsx +++ b/src/behaviours/navigating-between-routes.test.tsx @@ -31,7 +31,7 @@ describe("navigating between routes", () => { describe("given route without path parameters", () => { beforeEach(async () => { - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.register(testRouteWithoutPathParametersInjectable); rendererDi.register(testRouteWithoutPathParametersComponentInjectable); }); @@ -102,7 +102,7 @@ describe("navigating between routes", () => { describe("given route with optional path parameters", () => { beforeEach(async () => { - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.register(routeWithOptionalPathParametersInjectable); rendererDi.register(routeWithOptionalPathParametersComponentInjectable); }); diff --git a/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap b/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap index d0f2586641..5cdee87f39 100644 --- a/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap +++ b/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap @@ -124,7 +124,7 @@ exports[`preferences - closing-preferences given accessing preferences directly >
Select...
@@ -150,7 +150,7 @@ exports[`preferences - closing-preferences given accessing preferences directly >
Select...
@@ -235,7 +235,7 @@ exports[`preferences - closing-preferences given accessing preferences directly > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -460,7 +453,7 @@ exports[`preferences - closing-preferences given accessing preferences directly >
+
`; @@ -684,6 +680,9 @@ exports[`preferences - closing-preferences given accessing preferences directly
+
`; @@ -692,6 +691,9 @@ exports[`preferences - closing-preferences given accessing preferences directly
Some front page
+
`; @@ -700,6 +702,9 @@ exports[`preferences - closing-preferences given accessing preferences directly
Some front page
+
`; @@ -827,7 +832,7 @@ exports[`preferences - closing-preferences given already in a page and then navi >
Select...
@@ -853,7 +858,7 @@ exports[`preferences - closing-preferences given already in a page and then navi >
Select...
@@ -938,7 +943,7 @@ exports[`preferences - closing-preferences given already in a page and then navi > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -1163,7 +1161,7 @@ exports[`preferences - closing-preferences given already in a page and then navi >
+
`; @@ -1387,6 +1388,9 @@ exports[`preferences - closing-preferences given already in a page and then navi
+
`; @@ -1529,6 +1533,9 @@ exports[`preferences - closing-preferences given already in a page and then navi +
`; @@ -1671,5 +1678,8 @@ exports[`preferences - closing-preferences given already in a page and then navi +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap index c1a12d60ba..7cd89769f5 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap @@ -199,6 +199,9 @@ exports[`preferences - navigation to application preferences given in some child +
`; @@ -314,7 +317,7 @@ exports[`preferences - navigation to application preferences given in some child >
Select...
@@ -340,7 +343,7 @@ exports[`preferences - navigation to application preferences given in some child >
Select...
@@ -425,7 +428,7 @@ exports[`preferences - navigation to application preferences given in some child > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -650,7 +646,7 @@ exports[`preferences - navigation to application preferences given in some child >
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap index 6d5284f7bb..9f99faceb3 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap @@ -112,7 +112,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh >
Select...
@@ -138,7 +138,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh >
Select...
@@ -223,7 +223,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -448,7 +441,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh >
+
`; @@ -668,7 +664,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh >
Select...
@@ -694,7 +690,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh >
Select...
@@ -778,7 +774,7 @@ exports[`preferences - navigation to editor preferences given in preferences, wh >
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap b/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap index e8914c3f5a..ec00c0e498 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap @@ -1,778 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`preferences - navigation to extension specific preferences given in preferences, when rendered given extension with registered tab shows extension tab in general area 1`] = ` - -`; - -exports[`preferences - navigation to extension specific preferences given in preferences, when rendered given extension with registered tab when navigating to specific extension tab renders 1`] = ` -
-
- -
-
-
-

- registered-tab-page-id - preferences -

-
-
- License item - -
-
-
-
-
-
-
-
-
-
-
-
-
- - - close - - -
- -
-
-
-
-
-
-`; - -exports[`preferences - navigation to extension specific preferences given in preferences, when rendered given extensions with tabs having same id when navigating to first extension tab renders 1`] = ` -
-
- -
-
-
-

- registered-tab-page-id - preferences -

-
-
- License item - -
-
-
-
-
-
-
-
-
-
-
-
-
- - - close - - -
- -
-
-
-
-
-
-`; - -exports[`preferences - navigation to extension specific preferences given in preferences, when rendered given extensions with tabs having same id when navigating to second extension tab renders 1`] = ` -
-
- -
-
-
-

- duplicated-tab-page-id - preferences -

-
-
- Another metrics - -
-
-
-
-
-
-
-
-
-
-
-
-
- - - close - - -
- -
-
-
-
-
-
-`; - -exports[`preferences - navigation to extension specific preferences given in preferences, when rendered given multiple extensions with specific preferences, when navigating to extension specific preferences page renders 1`] = ` -
-
- -
-
-
-

- some-test-extension-id - preferences -

-
-
- Some preference item - -
-
-
-
-
-
-
-
-
-
-
-
-
- - - close - - -
- -
-
-
-
-
-
-`; - exports[`preferences - navigation to extension specific preferences given in preferences, when rendered renders 1`] = `
Select...
@@ -911,7 +138,7 @@ exports[`preferences - navigation to extension specific preferences given in pre >
Select...
@@ -996,7 +223,7 @@ exports[`preferences - navigation to extension specific preferences given in pre > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -1221,7 +441,7 @@ exports[`preferences - navigation to extension specific preferences given in pre >
+
`; @@ -1387,37 +610,15 @@ exports[`preferences - navigation to extension specific preferences given in pre
@@ -1452,7 +653,7 @@ exports[`preferences - navigation to extension specific preferences given in pre >
Select...
@@ -1478,7 +679,7 @@ exports[`preferences - navigation to extension specific preferences given in pre >
Select...
@@ -1563,7 +764,7 @@ exports[`preferences - navigation to extension specific preferences given in pre > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -1788,7 +982,7 @@ exports[`preferences - navigation to extension specific preferences given in pre >
+
`; @@ -1954,37 +1151,15 @@ exports[`preferences - navigation to extension specific preferences given in pre
@@ -2001,8 +1176,7 @@ exports[`preferences - navigation to extension specific preferences given in pre id="extensions" >

- some-test-extension-id - preferences + Extensions

+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap index 7f8ad70c1a..8ab873fac4 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap @@ -112,7 +112,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences >
Select...
@@ -138,7 +138,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences >
Select...
@@ -223,7 +223,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -448,7 +441,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences >
+
`; @@ -664,7 +660,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences >
Download mirror for kubectl
@@ -690,7 +686,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences >
Repositories
+
@@ -974,5 +983,8 @@ exports[`preferences - navigation to kubernetes preferences given in preferences
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap index ec521a3ad5..4710bb1957 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap @@ -112,7 +112,7 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe >
Select...
@@ -138,7 +138,7 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe >
Select...
@@ -223,7 +223,7 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -448,7 +441,7 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe >
+
`; @@ -732,5 +728,8 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap b/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap index 278461e4b9..68901d7a4d 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap @@ -185,6 +185,9 @@ exports[`preferences - navigation to telemetry preferences given URL for Sentry +
`; @@ -300,7 +303,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, >
Select...
@@ -326,7 +329,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, >
Select...
@@ -411,7 +414,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -636,7 +632,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, >
+
`; @@ -845,7 +844,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, >
Select...
@@ -871,7 +870,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, >
Select...
@@ -956,7 +955,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -1181,7 +1173,7 @@ exports[`preferences - navigation to telemetry preferences given in preferences, >
+
`; @@ -1439,6 +1434,9 @@ exports[`preferences - navigation to telemetry preferences given in preferences,
+
`; @@ -1578,5 +1576,8 @@ exports[`preferences - navigation to telemetry preferences given no URL for Sent +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap index 76064feb67..e0187e504b 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap @@ -112,7 +112,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, >
Select...
@@ -138,7 +138,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, >
Select...
@@ -223,7 +223,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -448,7 +441,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, >
+
`; @@ -689,7 +685,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, >
Select...
@@ -715,7 +711,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, >
- -
+ +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
@@ -850,5 +903,8 @@ exports[`preferences - navigation to terminal preferences given in preferences, +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap index e44a035cd0..367ac869ba 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`preferences - navigation using application menu renders 1`] = `
`; +exports[`preferences - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`preferences - navigation using application menu when navigating to preferences using application menu renders 1`] = `
@@ -114,7 +120,7 @@ exports[`preferences - navigation using application menu when navigating to pref >
Select...
@@ -140,7 +146,7 @@ exports[`preferences - navigation using application menu when navigating to pref >
Select...
@@ -225,7 +231,7 @@ exports[`preferences - navigation using application menu when navigating to pref > - This setting is to change the registry URL for installing extensions by name. - - If you are unable to access the default registry ( - https://registry.npmjs.org - ) - - you can change it in your + This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your .npmrc -  file or in the input below. + file or in the input below.

- Select... + Stable
Select...
@@ -450,7 +449,7 @@ exports[`preferences - navigation using application menu when navigating to pref >
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-using-tray.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-using-tray.test.ts.snap new file mode 100644 index 0000000000..57b42580d9 --- /dev/null +++ b/src/behaviours/preferences/__snapshots__/navigation-using-tray.test.ts.snap @@ -0,0 +1,542 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`show-about-using-tray renders 1`] = ` + +
+
+
+ +`; + +exports[`show-about-using-tray when navigating using tray renders 1`] = ` + +
+
+ +
+
+
+

+ Application +

+
+
+ Theme + +
+
+ + +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+ Extension Install Registry + +
+
+ + +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
+
+

+ This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your + + .npmrc + + file or in the input below. +

+
+ +
+
+
+
+
+
+ Start-up + +
+ +
+
+
+
+ Update Channel + +
+
+ + +
+
+
+ Stable +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+ Locale Timezone + +
+
+ + +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ + + close + + +
+ +
+
+
+
+
+
+
+ +`; diff --git a/src/behaviours/preferences/closing-preferences.test.tsx b/src/behaviours/preferences/closing-preferences.test.tsx index 60ab5719b2..3d3eaa89dc 100644 --- a/src/behaviours/preferences/closing-preferences.test.tsx +++ b/src/behaviours/preferences/closing-preferences.test.tsx @@ -10,10 +10,6 @@ import { getApplicationBuilder } from "../../renderer/components/test-utils/get- import currentPathInjectable from "../../renderer/routes/current-path.injectable"; import { routeInjectionToken } from "../../common/front-end-routing/route-injection-token"; import { computed } from "mobx"; -import type { UserStore } from "../../common/user-store"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; import { preferenceNavigationItemInjectionToken } from "../../renderer/components/+preferences/preferences-navigation/preference-navigation-items.injectable"; import routeIsActiveInjectable from "../../renderer/routes/route-is-active.injectable"; import { Preferences } from "../../renderer/components/+preferences"; @@ -33,23 +29,13 @@ describe("preferences - closing-preferences", () => { beforeEach(() => { applicationBuilder = getApplicationBuilder(); - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.register(testPreferencesRouteInjectable); rendererDi.register(testPreferencesRouteComponentInjectable); rendererDi.register(testFrontPageRouteInjectable); rendererDi.register(testFrontPageRouteComponentInjectable); rendererDi.register(testNavigationItemInjectable); - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = { themeOptions: [] } as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - rendererDi.override(navigateToFrontPageInjectable, (di) => { const navigateToRoute = di.inject(navigateToRouteInjectionToken); const testFrontPage = di.inject(testFrontPageRouteInjectable); @@ -66,7 +52,7 @@ describe("preferences - closing-preferences", () => { let rendererDi: DiContainer; beforeEach(async () => { - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.override(observableHistoryInjectable, () => { const historyFake = createMemoryHistory({ initialEntries: ["/some-test-path"], @@ -139,7 +125,7 @@ describe("preferences - closing-preferences", () => { let rendererDi: DiContainer; beforeEach(async () => { - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.override(observableHistoryInjectable, () => { const historyFake = createMemoryHistory({ initialEntries: ["/preferences/app"], diff --git a/src/behaviours/preferences/navigation-to-application-preferences.test.ts b/src/behaviours/preferences/navigation-to-application-preferences.test.ts index 67d525cfb3..c91f091323 100644 --- a/src/behaviours/preferences/navigation-to-application-preferences.test.ts +++ b/src/behaviours/preferences/navigation-to-application-preferences.test.ts @@ -5,30 +5,13 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { UserStore } from "../../common/user-store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import navigateToProxyPreferencesInjectable - from "../../common/front-end-routing/routes/preferences/proxy/navigate-to-proxy-preferences.injectable"; +import navigateToProxyPreferencesInjectable from "../../common/front-end-routing/routes/preferences/proxy/navigate-to-proxy-preferences.injectable"; describe("preferences - navigation to application preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = ({ themeOptions: [] }) as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in some child page of preferences, when rendered", () => { diff --git a/src/behaviours/preferences/navigation-to-editor-preferences.test.ts b/src/behaviours/preferences/navigation-to-editor-preferences.test.ts index 43824371eb..1b74b94eb4 100644 --- a/src/behaviours/preferences/navigation-to-editor-preferences.test.ts +++ b/src/behaviours/preferences/navigation-to-editor-preferences.test.ts @@ -5,29 +5,12 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { UserStore } from "../../common/user-store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; describe("preferences - navigation to editor preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - editorConfiguration: { minimap: {}, tabSize: 42, fontSize: 42 }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = ({ themeOptions: [] }) as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in preferences, when rendered", () => { diff --git a/src/behaviours/preferences/navigation-to-extension-specific-preferences.test.tsx b/src/behaviours/preferences/navigation-to-extension-specific-preferences.test.tsx index 23c8c5560a..c81d3e9ac1 100644 --- a/src/behaviours/preferences/navigation-to-extension-specific-preferences.test.tsx +++ b/src/behaviours/preferences/navigation-to-extension-specific-preferences.test.tsx @@ -5,32 +5,16 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { UserStore } from "../../common/user-store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import type { LensRendererExtension } from "../../extensions/lens-renderer-extension"; import React from "react"; -import { getRendererExtensionFake } from "../../renderer/components/test-utils/get-renderer-extension-fake"; import "@testing-library/jest-dom/extend-expect"; +import type { FakeExtensionData } from "../../renderer/components/test-utils/get-renderer-extension-fake"; +import { getRendererExtensionFakeFor } from "../../renderer/components/test-utils/get-renderer-extension-fake"; describe("preferences - navigation to extension specific preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = { themeOptions: [] } as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in preferences, when rendered", () => { @@ -62,6 +46,7 @@ describe("preferences - navigation to extension specific preferences", () => { describe("given multiple extensions with specific preferences, when navigating to extension specific preferences page", () => { beforeEach(async () => { + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const someTestExtension = getRendererExtensionFake(extensionStubWithExtensionSpecificPreferenceItems); const someOtherTestExtension = getRendererExtensionFake(someOtherExtensionStubWithExtensionSpecificPreferenceItems); @@ -88,6 +73,7 @@ describe("preferences - navigation to extension specific preferences", () => { describe("given multiple extensions with and without specific preferences, when navigating to extension specific preferences page", () => { beforeEach(async () => { + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const someTestExtension = getRendererExtensionFake(extensionStubWithExtensionSpecificPreferenceItems); const extensionWithoutPreferences = getRendererExtensionFake(extensionStubWithoutPreferences); const extensionWithSpecificTab = getRendererExtensionFake(extensionStubWithShowInPreferencesTab); @@ -109,10 +95,11 @@ describe("preferences - navigation to extension specific preferences", () => { }); describe("when extension with specific preferences is enabled", () => { - beforeEach(async () => { + beforeEach(() => { + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const testExtension = getRendererExtensionFake(extensionStubWithExtensionSpecificPreferenceItems); - await applicationBuilder.addExtensions(testExtension); + applicationBuilder.addExtensions(testExtension); }); it("renders", () => { @@ -168,6 +155,7 @@ describe("preferences - navigation to extension specific preferences", () => { describe("given extension with registered tab", () => { beforeEach(async () => { + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const extension = getRendererExtensionFake(extensionStubWithWithRegisteredTab); await applicationBuilder.addExtensions(extension); @@ -207,6 +195,7 @@ describe("preferences - navigation to extension specific preferences", () => { describe("given extension with few registered tabs", () => { beforeEach(async () => { + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const extension = getRendererExtensionFake(extensionStubWithWithRegisteredTabs); await applicationBuilder.addExtensions(extension); @@ -223,6 +212,7 @@ describe("preferences - navigation to extension specific preferences", () => { describe("given extensions with tabs having same id", () => { beforeEach(async () => { + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const extension = getRendererExtensionFake(extensionStubWithWithRegisteredTab); const otherExtension = getRendererExtensionFake(extensionStubWithWithSameRegisteredTab); @@ -288,9 +278,9 @@ describe("preferences - navigation to extension specific preferences", () => { }); }); -const extensionStubWithExtensionSpecificPreferenceItems: Partial = { - id: "some-test-extension-id", - +const extensionStubWithExtensionSpecificPreferenceItems: FakeExtensionData = { + id: "some-extension-id", + name: "some-extension-name", appPreferences: [ { title: "Some preference item", @@ -315,8 +305,9 @@ const extensionStubWithExtensionSpecificPreferenceItems: Partial = { +const someOtherExtensionStubWithExtensionSpecificPreferenceItems: FakeExtensionData = { id: "some-other-test-extension-id", + name: "some-other-test-extension-name", appPreferences: [ { @@ -331,12 +322,14 @@ const someOtherExtensionStubWithExtensionSpecificPreferenceItems: Partial = { +const extensionStubWithoutPreferences: FakeExtensionData = { id: "without-preferences-id", + name: "without-preferences-name", }; -const extensionStubWithShowInPreferencesTab: Partial = { +const extensionStubWithShowInPreferencesTab: FakeExtensionData = { id: "specified-preferences-page-id", + name: "specified-preferences-page-name", appPreferences: [ { @@ -352,8 +345,9 @@ const extensionStubWithShowInPreferencesTab: Partial = { ], }; -const extensionStubWithWithRegisteredTab: Partial = { +const extensionStubWithWithRegisteredTab: FakeExtensionData = { id: "registered-tab-page-id", + name: "registered-tab-page-name", appPreferences: [ { @@ -395,8 +389,9 @@ const extensionStubWithWithRegisteredTab: Partial = { }], }; -const extensionStubWithWithRegisteredTabs: Partial = { +const extensionStubWithWithRegisteredTabs: FakeExtensionData = { id: "hello-world-tab-page-id", + name: "hello-world-tab-page-name", appPreferences: [ { @@ -432,8 +427,9 @@ const extensionStubWithWithRegisteredTabs: Partial = { }], }; -const extensionStubWithWithSameRegisteredTab: Partial = { +const extensionStubWithWithSameRegisteredTab: FakeExtensionData = { id: "duplicated-tab-page-id", + name: "duplicated-tab-page-name", appPreferences: [ { diff --git a/src/behaviours/preferences/navigation-to-kubernetes-preferences.test.ts b/src/behaviours/preferences/navigation-to-kubernetes-preferences.test.ts index 31693a6883..e404e1f489 100644 --- a/src/behaviours/preferences/navigation-to-kubernetes-preferences.test.ts +++ b/src/behaviours/preferences/navigation-to-kubernetes-preferences.test.ts @@ -5,30 +5,12 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { UserStore } from "../../common/user-store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import { observable } from "mobx"; describe("preferences - navigation to kubernetes preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - syncKubeconfigEntries: observable.map(), - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = ({ themeOptions: [] }) as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in preferences, when rendered", () => { diff --git a/src/behaviours/preferences/navigation-to-proxy-preferences.test.ts b/src/behaviours/preferences/navigation-to-proxy-preferences.test.ts index c0b5dc1f25..3d2c8d6d2c 100644 --- a/src/behaviours/preferences/navigation-to-proxy-preferences.test.ts +++ b/src/behaviours/preferences/navigation-to-proxy-preferences.test.ts @@ -5,28 +5,12 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { UserStore } from "../../common/user-store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; describe("preferences - navigation to proxy preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = ({ themeOptions: [] }) as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in preferences, when rendered", () => { diff --git a/src/behaviours/preferences/navigation-to-telemetry-preferences.test.tsx b/src/behaviours/preferences/navigation-to-telemetry-preferences.test.tsx index bd972337c3..f16c2d809b 100644 --- a/src/behaviours/preferences/navigation-to-telemetry-preferences.test.tsx +++ b/src/behaviours/preferences/navigation-to-telemetry-preferences.test.tsx @@ -6,11 +6,8 @@ import type { RenderResult } from "@testing-library/react"; import React from "react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import { getRendererExtensionFake } from "../../renderer/components/test-utils/get-renderer-extension-fake"; -import type { UserStore } from "../../common/user-store"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; +import type { FakeExtensionData } from "../../renderer/components/test-utils/get-renderer-extension-fake"; +import { getRendererExtensionFakeFor } from "../../renderer/components/test-utils/get-renderer-extension-fake"; import navigateToTelemetryPreferencesInjectable from "../../common/front-end-routing/routes/preferences/telemetry/navigate-to-telemetry-preferences.injectable"; import sentryDnsUrlInjectable from "../../renderer/components/+preferences/sentry-dns-url.injectable"; @@ -19,18 +16,6 @@ describe("preferences - navigation to telemetry preferences", () => { beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = { themeOptions: [] } as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in preferences, when rendered", () => { @@ -62,8 +47,8 @@ describe("preferences - navigation to telemetry preferences", () => { describe("when extension with telemetry preference items gets enabled", () => { beforeEach(() => { - const testExtensionWithTelemetryPreferenceItems = - getRendererExtensionFake(extensionStubWithTelemetryPreferenceItems); + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); + const testExtensionWithTelemetryPreferenceItems = getRendererExtensionFake(extensionStubWithTelemetryPreferenceItems); applicationBuilder.addExtensions( testExtensionWithTelemetryPreferenceItems, @@ -106,18 +91,19 @@ describe("preferences - navigation to telemetry preferences", () => { }); it("given extensions but no telemetry preference items, does not show link for telemetry preferences", () => { - const testExtensionWithTelemetryPreferenceItems = - getRendererExtensionFake({ - id: "some-test-extension-id", - appPreferences: [ - { - title: "irrelevant", - id: "irrelevant", - showInPreferencesTab: "not-telemetry", - components: { Hint: () =>
, Input: () =>
}, - }, - ], - }); + const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); + const testExtensionWithTelemetryPreferenceItems = getRendererExtensionFake({ + id: "some-test-extension-id", + name: "some-test-extension-name", + appPreferences: [ + { + title: "irrelevant", + id: "irrelevant", + showInPreferencesTab: "not-telemetry", + components: { Hint: () =>
, Input: () =>
}, + }, + ], + }); applicationBuilder.addExtensions( testExtensionWithTelemetryPreferenceItems, @@ -133,7 +119,7 @@ describe("preferences - navigation to telemetry preferences", () => { let rendered: RenderResult; beforeEach(async () => { - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.override(sentryDnsUrlInjectable, () => "some-sentry-dns-url"); }); @@ -163,7 +149,7 @@ describe("preferences - navigation to telemetry preferences", () => { let rendered: RenderResult; beforeEach(async () => { - applicationBuilder.beforeSetups(({ rendererDi }) => { + applicationBuilder.beforeApplicationStart(({ rendererDi }) => { rendererDi.override(sentryDnsUrlInjectable, () => null); }); @@ -186,8 +172,9 @@ describe("preferences - navigation to telemetry preferences", () => { }); }); -const extensionStubWithTelemetryPreferenceItems = { +const extensionStubWithTelemetryPreferenceItems: FakeExtensionData = { id: "some-test-extension-id", + name: "some-test-extension-name", appPreferences: [ { title: "Some telemetry-preference item", diff --git a/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts b/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts index 7b273ca8e6..73eff39006 100644 --- a/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts +++ b/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts @@ -5,34 +5,12 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { UserStore } from "../../common/user-store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import { observable } from "mobx"; -import defaultShellInjectable from "../../renderer/components/+preferences/default-shell.injectable"; describe("preferences - navigation to terminal preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeSetups(({ rendererDi }) => { - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - syncKubeconfigEntries: observable.map(), - terminalConfig: { fontSize: 42 }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - rendererDi.override(defaultShellInjectable, () => "some-default-shell"); - - const themeStoreStub = ({ themeOptions: [] }) as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); }); describe("given in preferences, when rendered", () => { diff --git a/src/behaviours/preferences/navigation-using-application-menu.test.ts b/src/behaviours/preferences/navigation-using-application-menu.test.ts index 42664dbfdf..e12aa85a44 100644 --- a/src/behaviours/preferences/navigation-using-application-menu.test.ts +++ b/src/behaviours/preferences/navigation-using-application-menu.test.ts @@ -6,11 +6,6 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; -import type { UserStore } from "../../common/user-store"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import type { ThemeStore } from "../../renderer/theme.store"; -import themeStoreInjectable from "../../renderer/theme-store.injectable"; describe("preferences - navigation using application menu", () => { let applicationBuilder: ApplicationBuilder; @@ -19,20 +14,6 @@ describe("preferences - navigation using application menu", () => { beforeEach(async () => { applicationBuilder = getApplicationBuilder(); - applicationBuilder.beforeSetups(({ rendererDi, mainDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - - const userStoreStub = { - extensionRegistryUrl: { customUrl: "some-custom-url" }, - } as unknown as UserStore; - - rendererDi.override(userStoreInjectable, () => userStoreStub); - - const themeStoreStub = { themeOptions: [] } as unknown as ThemeStore; - - rendererDi.override(themeStoreInjectable, () => themeStoreStub); - }); - rendered = await applicationBuilder.render(); }); @@ -47,8 +28,8 @@ describe("preferences - navigation using application menu", () => { }); describe("when navigating to preferences using application menu", () => { - beforeEach(() => { - applicationBuilder.applicationMenu.click("root.preferences"); + beforeEach(async () => { + await applicationBuilder.applicationMenu.click("root.preferences"); }); it("renders", () => { diff --git a/src/behaviours/preferences/navigation-using-tray.test.ts b/src/behaviours/preferences/navigation-using-tray.test.ts new file mode 100644 index 0000000000..065cc54f4b --- /dev/null +++ b/src/behaviours/preferences/navigation-using-tray.test.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RenderResult } from "@testing-library/react"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; + +describe("show-about-using-tray", () => { + let applicationBuilder: ApplicationBuilder; + let rendered: RenderResult; + + beforeEach(async () => { + applicationBuilder = getApplicationBuilder(); + + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show application preferences page yet", () => { + const actual = rendered.queryByTestId("application-preferences-page"); + + expect(actual).toBeNull(); + }); + + describe("when navigating using tray", () => { + beforeEach(async () => { + await applicationBuilder.tray.click("open-preferences"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows application preferences page", () => { + const actual = rendered.getByTestId("application-preferences-page"); + + expect(actual).not.toBeNull(); + }); + }); +}); diff --git a/src/behaviours/utils.ts b/src/behaviours/utils.ts new file mode 100644 index 0000000000..1a20ab249b --- /dev/null +++ b/src/behaviours/utils.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { RenderResult } from "@testing-library/react"; + +export function getSidebarItem(rendered: RenderResult, itemId: string) { + return rendered + .queryAllByTestId("sidebar-item") + .find((x) => x.dataset.idTest === itemId); +} diff --git a/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap b/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap index 368d132edb..05eee498bf 100644 --- a/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap +++ b/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`welcome - navigation using application menu renders 1`] = `
`; +exports[`welcome - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`welcome - navigation using application menu when navigating to welcome using application menu renders 1`] = `
@@ -27,16 +33,17 @@ exports[`welcome - navigation using application menu when navigating to welcome style="width: 320px;" >

- Welcome to - OpenLens - 5! + Welcome to OpenLens 5!

- To get you started we have auto-detected your clusters in your kubeconfig file and added them to the catalog, your centralized view for managing all your cloud-native resources. -
-
- If you have any questions or feedback, please join our + To get you started we have auto-detected your clusters in your + kubeconfig file and added them to the catalog, your centralized + + view for managing all your cloud-native resources. +
+
+ If you have any questions or feedback, please join our

+
`; diff --git a/src/behaviours/welcome/navigation-using-application-menu.test.ts b/src/behaviours/welcome/navigation-using-application-menu.test.ts index daab31c40e..a9f09c783c 100644 --- a/src/behaviours/welcome/navigation-using-application-menu.test.ts +++ b/src/behaviours/welcome/navigation-using-application-menu.test.ts @@ -6,16 +6,13 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; describe("welcome - navigation using application menu", () => { let applicationBuilder: ApplicationBuilder; let rendered: RenderResult; beforeEach(async () => { - applicationBuilder = getApplicationBuilder().beforeSetups(({ mainDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - }); + applicationBuilder = getApplicationBuilder(); rendered = await applicationBuilder.render(); }); @@ -31,8 +28,8 @@ describe("welcome - navigation using application menu", () => { }); describe("when navigating to welcome using application menu", () => { - beforeEach(() => { - applicationBuilder.applicationMenu.click("help.welcome"); + beforeEach(async () => { + await applicationBuilder.applicationMenu.click("help.welcome"); }); it("renders", () => { diff --git a/src/common/__tests__/base-store.test.ts b/src/common/__tests__/base-store.test.ts index 07cea7039e..e7eb952111 100644 --- a/src/common/__tests__/base-store.test.ts +++ b/src/common/__tests__/base-store.test.ts @@ -30,9 +30,9 @@ interface TestStoreModel { } class TestStore extends BaseStore { - @observable a: string; - @observable b: string; - @observable c: string; + @observable a = ""; + @observable b = ""; + @observable c = ""; constructor() { super({ @@ -81,16 +81,13 @@ class TestStore extends BaseStore { describe("BaseStore", () => { let store: TestStore; - beforeEach(async () => { + beforeEach(() => { const mainDi = getDiForUnitTesting({ doGeneralOverrides: true }); mainDi.override(directoryForUserDataInjectable, () => "some-user-data-directory"); mainDi.permitSideEffects(getConfigurationFileModelInjectable); mainDi.permitSideEffects(appVersionInjectable); - await mainDi.runSetups(); - - store = undefined; TestStore.resetInstance(); const mockOpts = { diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index ccc2d897b9..ca52f6f1d2 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -8,22 +8,23 @@ import mockFs from "mock-fs"; import path from "path"; import fse from "fs-extra"; import type { Cluster } from "../cluster/cluster"; -import { ClusterStore } from "../cluster-store/cluster-store"; +import type { ClusterStore } from "../cluster-store/cluster-store"; import { Console } from "console"; import { stdout, stderr } from "process"; import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; import type { ClusterModel } from "../cluster-types"; -import type { - DiContainer, -} from "@ogre-tools/injectable"; - +import type { DiContainer } from "@ogre-tools/injectable"; import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; - import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../get-configuration-file-model/app-version/app-version.injectable"; +import assert from "assert"; +import directoryForTempInjectable from "../app-paths/directory-for-temp/directory-for-temp.injectable"; +import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable"; +import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; console = new Console(stdout, stderr); @@ -84,15 +85,17 @@ describe("cluster-store", () => { mockFs(); - mainDi.override(clusterStoreInjectable, (di) => ClusterStore.createInstance({ createCluster: di.inject(createClusterInjectionToken) })); mainDi.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + mainDi.override(directoryForTempInjectable, () => "some-temp-directory"); + mainDi.override(kubectlBinaryNameInjectable, () => "kubectl"); + mainDi.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + mainDi.override(normalizedPlatformInjectable, () => "darwin"); mainDi.permitSideEffects(getConfigurationFileModelInjectable); mainDi.permitSideEffects(appVersionInjectable); + mainDi.permitSideEffects(clusterStoreInjectable); - await mainDi.runSetups(); - - createCluster = mainDi.inject(createClusterInjectionToken); + mainDi.unoverride(clusterStoreInjectable); }); afterEach(() => { @@ -107,10 +110,6 @@ describe("cluster-store", () => { getCustomKubeConfigDirectoryInjectable, ); - // TODO: Remove these by removing Singleton base-class from BaseStore - ClusterStore.getInstance(false)?.unregisterIpcListener(); - ClusterStore.resetInstance(); - const mockOpts = { "some-directory-for-user-data": { "lens-cluster-store.json": JSON.stringify({}), @@ -119,7 +118,11 @@ describe("cluster-store", () => { mockFs(mockOpts); + createCluster = mainDi.inject(createClusterInjectionToken); + clusterStore = mainDi.inject(clusterStoreInjectable); + + clusterStore.unregisterIpcListener(); }); afterEach(() => { @@ -148,6 +151,8 @@ describe("cluster-store", () => { it("adds new cluster to store", async () => { const storedCluster = clusterStore.getById("foo"); + assert(storedCluster); + expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/some-directory-for-user-data"); expect(storedCluster.preferences.icon).toBe( @@ -199,8 +204,6 @@ describe("cluster-store", () => { describe("config with existing clusters", () => { beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { "temp-kube-config": kubeconfig, "some-directory-for-user-data": { @@ -239,6 +242,8 @@ describe("cluster-store", () => { mockFs(mockOpts); + createCluster = mainDi.inject(createClusterInjectionToken); + clusterStore = mainDi.inject(clusterStoreInjectable); }); @@ -249,6 +254,8 @@ describe("cluster-store", () => { it("allows to retrieve a cluster", () => { const storedCluster = clusterStore.getById("cluster1"); + assert(storedCluster); + expect(storedCluster.id).toBe("cluster1"); expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); @@ -287,8 +294,6 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; - ClusterStore.resetInstance(); - const mockOpts = { "invalid-kube-config": invalidKubeconfig, "valid-kube-config": kubeconfig, @@ -321,6 +326,8 @@ users: mockFs(mockOpts); + createCluster = mainDi.inject(createClusterInjectionToken); + clusterStore = mainDi.inject(clusterStoreInjectable); }); @@ -337,7 +344,6 @@ users: describe("pre 3.6.0-beta.1 config with an existing cluster", () => { beforeEach(() => { - ClusterStore.resetInstance(); const mockOpts = { "some-directory-for-user-data": { "lens-cluster-store.json": JSON.stringify({ @@ -363,6 +369,10 @@ users: mockFs(mockOpts); + mainDi.override(appVersionInjectable, () => "3.6.0"); + + createCluster = mainDi.inject(createClusterInjectionToken); + clusterStore = mainDi.inject(clusterStoreInjectable); }); @@ -379,6 +389,7 @@ users: it("migrates to modern format with icon not in file", async () => { const { icon } = clusterStore.clustersList[0].preferences; + assert(icon); expect(icon.startsWith("data:;base64,")).toBe(true); }); }); diff --git a/src/common/__tests__/event-bus.test.ts b/src/common/__tests__/event-bus.test.ts index e7165aac92..f90381eb44 100644 --- a/src/common/__tests__/event-bus.test.ts +++ b/src/common/__tests__/event-bus.test.ts @@ -5,7 +5,7 @@ import type { AppEvent } from "../app-event-bus/event-bus"; import { appEventBus } from "../app-event-bus/event-bus"; -import { Console } from "console"; +import { assert, Console } from "console"; import { stdout, stderr } from "process"; console = new Console(stdout, stderr); @@ -13,14 +13,15 @@ console = new Console(stdout, stderr); describe("event bus tests", () => { describe("emit", () => { it("emits an event", () => { - let event: AppEvent = null; + let event: AppEvent | undefined; appEventBus.addListener((data) => { event = data; }); appEventBus.emit({ name: "foo", action: "bar" }); - expect(event.name).toBe("foo"); + assert(event); + expect(event?.name).toBe("foo"); }); }); }); diff --git a/src/common/__tests__/event-emitter.test.ts b/src/common/__tests__/event-emitter.test.ts index 5a0e49aad0..cc71922182 100644 --- a/src/common/__tests__/event-emitter.test.ts +++ b/src/common/__tests__/event-emitter.test.ts @@ -21,7 +21,7 @@ describe("EventEmitter", () => { let called = false; const e = new EventEmitter<[]>(); - e.addListener(() => 0 as any, {}); + e.addListener(() => 0 as never, {}); e.addListener(() => { called = true; }, {}); e.emit(); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 9bc12d103d..cc8a4dc8fa 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -5,69 +5,20 @@ import { anyObject } from "jest-mock-extended"; import mockFs from "mock-fs"; -import logger from "../../main/logger"; import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../get-configuration-file-model/app-version/app-version.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; -import hotbarStoreInjectable from "../hotbar-store.injectable"; -import { HotbarStore } from "../hotbar-store"; +import hotbarStoreInjectable from "../hotbars/store.injectable"; +import type { HotbarStore } from "../hotbars/store"; +import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; +import { computed } from "mobx"; +import hasCategoryForEntityInjectable from "../catalog/has-category-for-entity.injectable"; import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; - -jest.mock("../../main/catalog/catalog-entity-registry", () => ({ - catalogEntityRegistry: { - items: [ - getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - - status: { - phase: "Running", - }, - - metadata: { - uid: "1dfa26e2ebab15780a3547e9c7fa785c", - name: "mycluster", - source: "local", - labels: {}, - }, - }), - - getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - - status: { - phase: "Running", - }, - - metadata: { - uid: "55b42c3c7ba3b04193416cda405269a5", - name: "my_shiny_cluster", - source: "remote", - labels: {}, - }, - }), - - getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - - status: { - phase: "Running", - }, - - metadata: { - uid: "catalog-entity", - name: "Catalog", - source: "app", - labels: {}, - }, - }), - ], - }, -})); +import loggerInjectable from "../logger.injectable"; +import type { Logger } from "../logger"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { return { @@ -84,75 +35,99 @@ function getMockCatalogEntity(data: Partial & CatalogEntityKi } as CatalogEntity; } -const testCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "test", - name: "test", - labels: {}, - }, -}); - -const minikubeCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "minikube", - name: "minikube", - labels: {}, - }, -}); - -const awsCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "aws", - name: "aws", - labels: {}, - }, -}); - describe("HotbarStore", () => { let di: DiContainer; let hotbarStore: HotbarStore; + let testCluster: CatalogEntity; + let minikubeCluster: CatalogEntity; + let awsCluster: CatalogEntity; + let loggerMock: jest.Mocked; beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); + (di as any).unoverride(hotbarStoreInjectable); + + testCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-test-id", + name: "my-test-cluster", + source: "local", + labels: {}, + }, + }); + minikubeCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-minikube-id", + name: "my-minikube-cluster", + source: "local", + labels: {}, + }, + }); + awsCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-aws-id", + name: "my-aws-cluster", + source: "local", + labels: {}, + }, + }); + + di.override(hasCategoryForEntityInjectable, () => () => true); + + loggerMock = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + silly: jest.fn(), + }; + + di.override(loggerInjectable, () => loggerMock); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); + + catalogEntityRegistry.addComputedSource("some-id", computed(() => [ + testCluster, + minikubeCluster, + awsCluster, + catalogCatalogEntity, + ])); + di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); - - di.override(hotbarStoreInjectable, () => { - HotbarStore.resetInstance(); - - return HotbarStore.createInstance({ - catalogCatalogEntity: di.inject(catalogCatalogEntityInjectable), - }); - }); + di.permitSideEffects(hotbarStoreInjectable); }); afterEach(() => { mockFs.restore(); }); - describe("given no migrations", () => { - beforeEach(async () => { + describe("given no previous data in store, running all migrations", () => { + beforeEach(() => { mockFs(); - await di.runSetups(); - hotbarStore = di.inject(hotbarStoreInjectable); + + hotbarStore.load(); }); describe("load", () => { @@ -174,7 +149,7 @@ describe("HotbarStore", () => { }); it("initially adds catalog entity as first item", () => { - expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); + expect(hotbarStore.getActive().items[0]?.entity.name).toEqual("Catalog"); }); it("adds items", () => { @@ -186,7 +161,7 @@ describe("HotbarStore", () => { it("removes items", () => { hotbarStore.addToHotbar(testCluster); - hotbarStore.removeFromHotbar("test"); + hotbarStore.removeFromHotbar("some-test-id"); hotbarStore.removeFromHotbar("catalog-entity"); const items = hotbarStore.getActive().items.filter(Boolean); @@ -211,7 +186,7 @@ describe("HotbarStore", () => { hotbarStore.restackItems(1, 5); expect(hotbarStore.getActive().items[5]).toBeTruthy(); - expect(hotbarStore.getActive().items[5].entity.uid).toEqual("test"); + expect(hotbarStore.getActive().items[5]?.entity.uid).toEqual("some-test-id"); }); it("moves items down", () => { @@ -224,7 +199,7 @@ describe("HotbarStore", () => { const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - expect(items.slice(0, 4)).toEqual(["aws", "catalog-entity", "test", "minikube"]); + expect(items.slice(0, 4)).toEqual(["some-aws-id", "catalog-entity", "some-test-id", "some-minikube-id"]); }); it("moves items up", () => { @@ -237,28 +212,21 @@ describe("HotbarStore", () => { const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - expect(items.slice(0, 4)).toEqual(["catalog-entity", "minikube", "aws", "test"]); + expect(items.slice(0, 4)).toEqual(["catalog-entity", "some-minikube-id", "some-aws-id", "some-test-id"]); }); it("logs an error if cellIndex is out of bounds", () => { hotbarStore.add({ name: "hottest", id: "hottest" }); hotbarStore.setActiveHotbar("hottest"); - const { error } = logger; - const mocked = jest.fn(); - - logger.error = mocked; - hotbarStore.addToHotbar(testCluster, -1); - expect(mocked).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); hotbarStore.addToHotbar(testCluster, 12); - expect(mocked).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); hotbarStore.addToHotbar(testCluster, 13); - expect(mocked).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - - logger.error = error; + expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); }); it("throws an error if getId is invalid or returns not a string", () => { @@ -275,7 +243,7 @@ describe("HotbarStore", () => { hotbarStore.addToHotbar(testCluster); hotbarStore.restackItems(1, 1); - expect(hotbarStore.getActive().items[1].entity.uid).toEqual("test"); + expect(hotbarStore.getActive().items[1]?.entity.uid).toEqual("some-test-id"); }); it("new items takes first empty cell", () => { @@ -284,7 +252,7 @@ describe("HotbarStore", () => { hotbarStore.restackItems(0, 3); hotbarStore.addToHotbar(minikubeCluster); - expect(hotbarStore.getActive().items[0].entity.uid).toEqual("minikube"); + expect(hotbarStore.getActive().items[0]?.entity.uid).toEqual("some-minikube-id"); }); it("throws if invalid arguments provided", () => { @@ -315,10 +283,10 @@ describe("HotbarStore", () => { }); }); - describe("given pre beta-5 configurations", () => { - beforeEach(async () => { + describe("given data from 5.0.0-beta.3 and version being 5.0.0-beta.10", () => { + beforeEach(() => { const configurationToBeMigrated = { - "some-electron-app-path-for-user-data": { + "some-directory-for-user-data": { "lens-hotbar-store.json": JSON.stringify({ __internal__: { migrations: { @@ -332,7 +300,7 @@ describe("HotbarStore", () => { items: [ { entity: { - uid: "1dfa26e2ebab15780a3547e9c7fa785c", + uid: "some-aws-id", }, }, { @@ -381,15 +349,17 @@ describe("HotbarStore", () => { mockFs(configurationToBeMigrated); - await di.runSetups(); + di.override(appVersionInjectable, () => "5.0.0-beta.10"); hotbarStore = di.inject(hotbarStoreInjectable); + + hotbarStore.load(); }); it("allows to retrieve a hotbar", () => { - const hotbar = hotbarStore.getById("3caac17f-aec2-4723-9694-ad204465d935"); + const hotbar = hotbarStore.findById("3caac17f-aec2-4723-9694-ad204465d935"); - expect(hotbar.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); + expect(hotbar?.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); }); it("clears cells without entity", () => { @@ -403,17 +373,9 @@ describe("HotbarStore", () => { expect(items[0]).toEqual({ entity: { - name: "mycluster", + name: "my-aws-cluster", source: "local", - uid: "1dfa26e2ebab15780a3547e9c7fa785c", - }, - }); - - expect(items[1]).toEqual({ - entity: { - name: "my_shiny_cluster", - source: "remote", - uid: "55b42c3c7ba3b04193416cda405269a5", + uid: "some-aws-id", }, }); }); diff --git a/src/common/__tests__/system-ca.test.ts b/src/common/__tests__/system-ca.test.ts index 0d962ee368..473fe6ed57 100644 --- a/src/common/__tests__/system-ca.test.ts +++ b/src/common/__tests__/system-ca.test.ts @@ -6,13 +6,14 @@ import https from "https"; import os from "os"; import { getMacRootCA, getWinRootCA, injectCAs, DSTRootCAX3 } from "../system-ca"; import { dependencies, devDependencies } from "../../../package.json"; +import assert from "assert"; const deps = { ...dependencies, ...devDependencies }; // Skip the test if mac-ca is not installed, or os is not darwin (deps["mac-ca"] && os.platform().includes("darwin") ? describe: describe.skip)("inject CA for Mac", () => { // for reset https.globalAgent.options.ca after testing - let _ca: string | Buffer | (string | Buffer)[]; + let _ca: string | Buffer | (string | Buffer)[] | undefined; beforeEach(() => { _ca = https.globalAgent.options.ca; @@ -44,6 +45,7 @@ const deps = { ...dependencies, ...devDependencies }; injectCAs(osxCAs); const injected = https.globalAgent.options.ca; + assert(injected); expect(injected.includes(DSTRootCAX3)).toBeFalsy(); }); }); @@ -51,7 +53,7 @@ const deps = { ...dependencies, ...devDependencies }; // Skip the test if win-ca is not installed, or os is not win32 (deps["win-ca"] && os.platform().includes("win32") ? describe: describe.skip)("inject CA for Windows", () => { // for reset https.globalAgent.options.ca after testing - let _ca: string | Buffer | (string | Buffer)[]; + let _ca: string | Buffer | (string | Buffer)[] | undefined; beforeEach(() => { _ca = https.globalAgent.options.ca; diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 9fb3713be1..238fc5bce8 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -21,7 +21,7 @@ jest.mock("electron", () => ({ }, })); -import { UserStore } from "../user-store"; +import type { UserStore } from "../user-store"; import { Console } from "console"; import { SemVer } from "semver"; import electron from "electron"; @@ -30,13 +30,11 @@ import userStoreInjectable from "../user-store/user-store.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import type { ClusterStoreModel } from "../cluster-store/cluster-store"; -import { defaultTheme } from "../vars"; +import { defaultThemeId } from "../vars"; import writeFileInjectable from "../fs/write-file.injectable"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; -import getConfigurationFileModelInjectable - from "../get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable - from "../get-configuration-file-model/app-version/app-version.injectable"; +import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; +import appVersionInjectable from "../get-configuration-file-model/app-version/app-version.injectable"; console = new Console(stdout, stderr); @@ -44,23 +42,22 @@ describe("user store tests", () => { let userStore: UserStore; let di: DiContainer; - beforeEach(async () => { + beforeEach(() => { di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs(); - di.override(writeFileInjectable, () => () => undefined); + di.override(writeFileInjectable, () => () => Promise.resolve()); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(userStoreInjectable, () => UserStore.createInstance()); - di.permitSideEffects(getConfigurationFileModelInjectable); - di.permitSideEffects(appVersionInjectable); - await di.runSetups(); + di.permitSideEffects(appVersionInjectable); + di.permitSideEffects(userStoreInjectable); + + di.unoverride(userStoreInjectable); }); afterEach(() => { - UserStore.resetInstance(); mockFs.restore(); }); @@ -80,7 +77,7 @@ describe("user store tests", () => { userStore.httpsProxy = "abcd://defg"; expect(userStore.httpsProxy).toBe("abcd://defg"); - expect(userStore.colorTheme).toBe(defaultTheme); + expect(userStore.colorTheme).toBe(defaultThemeId); userStore.colorTheme = "light"; expect(userStore.colorTheme).toBe("light"); @@ -89,7 +86,7 @@ describe("user store tests", () => { it("correctly resets theme to default value", async () => { userStore.colorTheme = "some other theme"; userStore.resetTheme(); - expect(userStore.colorTheme).toBe(defaultTheme); + expect(userStore.colorTheme).toBe(defaultThemeId); }); it("correctly calculates if the last seen version is an old release", () => { @@ -130,6 +127,8 @@ describe("user store tests", () => { }, }); + di.override(appVersionInjectable, () => "10.0.0"); + userStore = di.inject(userStoreInjectable); }); diff --git a/src/common/app-event-bus/app-event-bus.injectable.ts b/src/common/app-event-bus/app-event-bus.injectable.ts index 8ba0412ba4..31ed3dd3a1 100644 --- a/src/common/app-event-bus/app-event-bus.injectable.ts +++ b/src/common/app-event-bus/app-event-bus.injectable.ts @@ -8,6 +8,7 @@ import { appEventBus } from "./event-bus"; const appEventBusInjectable = getInjectable({ id: "app-event-bus", instantiate: () => appEventBus, + causesSideEffects: true, }); export default appEventBusInjectable; diff --git a/src/common/app-paths/app-path-injection-token.ts b/src/common/app-paths/app-path-injection-token.ts index 3b03e44daf..e29bcdbebf 100644 --- a/src/common/app-paths/app-path-injection-token.ts +++ b/src/common/app-paths/app-path-injection-token.ts @@ -4,12 +4,9 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; import type { PathName } from "./app-path-names"; -import { createChannel } from "../ipc-channel/create-channel/create-channel"; export type AppPaths = Record; export const appPathsInjectionToken = getInjectionToken({ id: "app-paths-token" }); -export const appPathsIpcChannel = createChannel("app-paths"); - diff --git a/src/common/app-paths/app-paths-channel.injectable.ts b/src/common/app-paths/app-paths-channel.injectable.ts new file mode 100644 index 0000000000..99fc738b41 --- /dev/null +++ b/src/common/app-paths/app-paths-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AppPaths } from "./app-path-injection-token"; +import type { RequestChannel } from "../utils/channel/request-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type AppPathsChannel = RequestChannel; + +const appPathsChannelInjectable = getInjectable({ + id: "app-paths-channel", + + instantiate: (): AppPathsChannel => ({ + id: "app-paths", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default appPathsChannelInjectable; diff --git a/src/common/app-paths/app-paths-state.injectable.ts b/src/common/app-paths/app-paths-state.injectable.ts new file mode 100644 index 0000000000..5487d428b2 --- /dev/null +++ b/src/common/app-paths/app-paths-state.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AppPaths } from "./app-path-injection-token"; + +const appPathsStateInjectable = getInjectable({ + id: "app-paths-state", + + instantiate: () => { + let state: AppPaths; + + return { + get: () =>{ + if (!state) { + throw new Error("Tried to get app paths before state is setupped."); + } + + return state; + }, + + set: (newState: AppPaths) => { + if (state) { + throw new Error("Tried to overwrite existing state of app paths."); + } + + state = newState; + }, + }; + }, +}); + +export default appPathsStateInjectable; diff --git a/src/common/app-paths/app-paths.injectable.ts b/src/common/app-paths/app-paths.injectable.ts new file mode 100644 index 0000000000..803f9e1380 --- /dev/null +++ b/src/common/app-paths/app-paths.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { appPathsInjectionToken } from "./app-path-injection-token"; +import appPathsStateInjectable from "./app-paths-state.injectable"; + +const appPathsInjectable = getInjectable({ + id: "app-paths", + instantiate: (di) => di.inject(appPathsStateInjectable).get(), + injectionToken: appPathsInjectionToken, +}); + +export default appPathsInjectable; diff --git a/src/common/app-paths/app-paths.test.ts b/src/common/app-paths/app-paths.test.ts index b436426e2e..5793295e0e 100644 --- a/src/common/app-paths/app-paths.test.ts +++ b/src/common/app-paths/app-paths.test.ts @@ -2,27 +2,27 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { DiContainer } from "@ogre-tools/injectable"; import type { AppPaths } from "./app-path-injection-token"; import { appPathsInjectionToken } from "./app-path-injection-token"; import getElectronAppPathInjectable from "../../main/app-paths/get-electron-app-path/get-electron-app-path.injectable"; -import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; import type { PathName } from "./app-path-names"; import setElectronAppPathInjectable from "../../main/app-paths/set-electron-app-path/set-electron-app-path.injectable"; import appNameInjectable from "../../main/app-paths/app-name/app-name.injectable"; import directoryForIntegrationTestingInjectable from "../../main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { DiContainer } from "@ogre-tools/injectable"; describe("app-paths", () => { - let mainDi: DiContainer; + let applicationBuilder: ApplicationBuilder; let rendererDi: DiContainer; - let runSetups: () => Promise; + let mainDi: DiContainer; beforeEach(() => { - const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + applicationBuilder = getApplicationBuilder(); - mainDi = dis.mainDi; - rendererDi = dis.rendererDi; - runSetups = dis.runSetups; + rendererDi = applicationBuilder.dis.rendererDi; + mainDi = applicationBuilder.dis.mainDi; const defaultAppPathsStub: AppPaths = { appData: "some-app-data", @@ -40,30 +40,32 @@ describe("app-paths", () => { recent: "some-recent", temp: "some-temp", videos: "some-videos", - userData: "some-irrelevant", + userData: "some-irrelevant-user-data", }; - mainDi.override( - getElectronAppPathInjectable, - () => - (key: PathName): string | null => - defaultAppPathsStub[key], - ); + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override( + getElectronAppPathInjectable, + () => + (key: PathName): string | null => + defaultAppPathsStub[key], + ); - mainDi.override( - setElectronAppPathInjectable, - () => - (key: PathName, path: string): void => { - defaultAppPathsStub[key] = path; - }, - ); + mainDi.override( + setElectronAppPathInjectable, + () => + (key: PathName, path: string): void => { + defaultAppPathsStub[key] = path; + }, + ); - mainDi.override(appNameInjectable, () => "some-app-name"); + mainDi.override(appNameInjectable, () => "some-app-name"); + }); }); describe("normally", () => { beforeEach(async () => { - await runSetups(); + await applicationBuilder.render(); }); it("given in renderer, when injecting app paths, returns application specific app paths", () => { @@ -115,12 +117,14 @@ describe("app-paths", () => { describe("when running integration tests", () => { beforeEach(async () => { - mainDi.override( - directoryForIntegrationTestingInjectable, - () => "some-integration-testing-app-data", - ); + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override( + directoryForIntegrationTestingInjectable, + () => "some-integration-testing-app-data", + ); + }); - await runSetups(); + await applicationBuilder.render(); }); it("given in renderer, when injecting path for app data, has integration specific app data path", () => { diff --git a/src/common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable.ts b/src/common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable.ts deleted file mode 100644 index 9c25407d4d..0000000000 --- a/src/common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { baseBinariesDir } from "../../vars"; - -const directoryForBundledBinariesInjectable = getInjectable({ - id: "directory-for-bundled-binaries", - instantiate: () => baseBinariesDir.get(), -}); - -export default directoryForBundledBinariesInjectable; diff --git a/src/common/application-update/application-update-status-channel.injectable.ts b/src/common/application-update/application-update-status-channel.injectable.ts new file mode 100644 index 0000000000..1365fd19af --- /dev/null +++ b/src/common/application-update/application-update-status-channel.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type ApplicationUpdateStatusEventId = + | "checking-for-updates" + | "no-updates-available" + | "download-for-update-started" + | "download-for-update-failed"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ApplicationUpdateStatusChannelMessage = { eventId: ApplicationUpdateStatusEventId; version?: string }; +export type ApplicationUpdateStatusChannel = MessageChannel; + +const applicationUpdateStatusChannelInjectable = getInjectable({ + id: "application-update-status-channel", + + instantiate: (): ApplicationUpdateStatusChannel => ({ + id: "application-update-status-channel", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default applicationUpdateStatusChannelInjectable; diff --git a/src/common/application-update/discovered-update-version/discovered-update-version.injectable.ts b/src/common/application-update/discovered-update-version/discovered-update-version.injectable.ts new file mode 100644 index 0000000000..60557de211 --- /dev/null +++ b/src/common/application-update/discovered-update-version/discovered-update-version.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import type { UpdateChannel } from "../update-channels"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +const discoveredUpdateVersionInjectable = getInjectable({ + id: "discovered-update-version", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox< + | { version: string; updateChannel: UpdateChannel } + | null + >( + "discovered-update-version", + null, + ); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default discoveredUpdateVersionInjectable; diff --git a/src/common/application-update/progress-of-update-download/progress-of-update-download.injectable.ts b/src/common/application-update/progress-of-update-download/progress-of-update-download.injectable.ts new file mode 100644 index 0000000000..26ecd1d618 --- /dev/null +++ b/src/common/application-update/progress-of-update-download/progress-of-update-download.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +export interface ProgressOfDownload { + percentage: number; +} + +const progressOfUpdateDownloadInjectable = getInjectable({ + id: "progress-of-update-download-state", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("progress-of-update-download", { percentage: 0 }); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default progressOfUpdateDownloadInjectable; diff --git a/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts b/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts new file mode 100644 index 0000000000..3d9101b672 --- /dev/null +++ b/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { SemVer } from "semver"; +import appVersionInjectable from "../../get-configuration-file-model/app-version/app-version.injectable"; +import type { UpdateChannelId } from "../update-channels"; +import { updateChannels } from "../update-channels"; + +const defaultUpdateChannelInjectable = getInjectable({ + id: "default-update-channel", + + instantiate: (di) => { + const appVersion = di.inject(appVersionInjectable); + + const currentReleaseChannel = new SemVer(appVersion).prerelease[0]?.toString() as UpdateChannelId; + + if (currentReleaseChannel && updateChannels[currentReleaseChannel]) { + return updateChannels[currentReleaseChannel]; + } + + return updateChannels.latest; + }, +}); + +export default defaultUpdateChannelInjectable; diff --git a/src/common/application-update/selected-update-channel/selected-update-channel.injectable.ts b/src/common/application-update/selected-update-channel/selected-update-channel.injectable.ts new file mode 100644 index 0000000000..ceb47aee5e --- /dev/null +++ b/src/common/application-update/selected-update-channel/selected-update-channel.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import { action, computed, observable } from "mobx"; +import type { UpdateChannel, UpdateChannelId } from "../update-channels"; +import { updateChannels } from "../update-channels"; +import defaultUpdateChannelInjectable from "./default-update-channel.injectable"; + +export interface SelectedUpdateChannel { + value: IComputedValue; + setValue: (channelId?: UpdateChannelId) => void; +} + +const selectedUpdateChannelInjectable = getInjectable({ + id: "selected-update-channel", + + instantiate: (di): SelectedUpdateChannel => { + const defaultUpdateChannel = di.inject(defaultUpdateChannelInjectable); + const state = observable.box(defaultUpdateChannel); + + return { + value: computed(() => state.get()), + + setValue: action((channelId) => { + const targetUpdateChannel = + channelId && updateChannels[channelId] + ? updateChannels[channelId] + : defaultUpdateChannel; + + state.set(targetUpdateChannel); + }), + }; + }, +}); + +export default selectedUpdateChannelInjectable; diff --git a/src/common/application-update/update-channels.ts b/src/common/application-update/update-channels.ts new file mode 100644 index 0000000000..c5f7b4b8c1 --- /dev/null +++ b/src/common/application-update/update-channels.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type UpdateChannelId = "alpha" | "beta" | "latest"; + +const latestChannel: UpdateChannel = { + id: "latest", + label: "Stable", + moreStableUpdateChannel: null, +}; + +const betaChannel: UpdateChannel = { + id: "beta", + label: "Beta", + moreStableUpdateChannel: latestChannel, +}; + +const alphaChannel: UpdateChannel = { + id: "alpha", + label: "Alpha", + moreStableUpdateChannel: betaChannel, +}; + +export const updateChannels: Record = { + latest: latestChannel, + beta: betaChannel, + alpha: alphaChannel, +}; + +export interface UpdateChannel { + readonly id: UpdateChannelId; + readonly label: string; + readonly moreStableUpdateChannel: UpdateChannel | null; +} diff --git a/src/common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable.ts b/src/common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable.ts new file mode 100644 index 0000000000..e1701d7952 --- /dev/null +++ b/src/common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +const updateIsBeingDownloadedInjectable = getInjectable({ + id: "update-is-being-downloaded", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("update-is-being-downloaded", false); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default updateIsBeingDownloadedInjectable; diff --git a/src/common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable.ts b/src/common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable.ts new file mode 100644 index 0000000000..21f1c14bec --- /dev/null +++ b/src/common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +const updatesAreBeingDiscoveredInjectable = getInjectable({ + id: "updates-are-being-discovered", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("updates-are-being-discovered", false); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default updatesAreBeingDiscoveredInjectable; diff --git a/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts b/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts new file mode 100644 index 0000000000..9901c04e30 --- /dev/null +++ b/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type AskBooleanAnswerChannel = MessageChannel<{ id: string; value: boolean }>; + +const askBooleanAnswerChannelInjectable = getInjectable({ + id: "ask-boolean-answer-channel", + + instantiate: (): AskBooleanAnswerChannel => ({ + id: "ask-boolean-answer", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default askBooleanAnswerChannelInjectable; diff --git a/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts b/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts new file mode 100644 index 0000000000..664337158f --- /dev/null +++ b/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type AskBooleanQuestionParameters = { id: string; title: string; question: string }; +export type AskBooleanQuestionChannel = MessageChannel; + +const askBooleanQuestionChannelInjectable = getInjectable({ + id: "ask-boolean-question-channel", + + instantiate: (): AskBooleanQuestionChannel => ({ + id: "ask-boolean-question", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default askBooleanQuestionChannelInjectable; diff --git a/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts b/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts index bbddddcca7..d681713a80 100644 --- a/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts +++ b/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts @@ -2,20 +2,30 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { kubernetesClusterCategory } from "../kubernetes-cluster"; + +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import kubernetesClusterCategoryInjectable from "../../catalog/categories/kubernetes-cluster.injectable"; +import type { KubernetesClusterCategory } from "../kubernetes-cluster"; + describe("kubernetesClusterCategory", () => { + let kubernetesClusterCategory: KubernetesClusterCategory; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + kubernetesClusterCategory = di.inject(kubernetesClusterCategoryInjectable); + }); + describe("filteredItems", () => { const item1 = { icon: "Icon", title: "Title", - // eslint-disable-next-line @typescript-eslint/no-empty-function onClick: () => {}, }; const item2 = { icon: "Icon 2", title: "Title 2", - // eslint-disable-next-line @typescript-eslint/no-empty-function onClick: () => {}, }; diff --git a/src/common/catalog-entities/general.ts b/src/common/catalog-entities/general.ts index 9c33499ec8..2d3b404ee0 100644 --- a/src/common/catalog-entities/general.ts +++ b/src/common/catalog-entities/general.ts @@ -5,8 +5,7 @@ import { navigate } from "../../renderer/navigation"; import type { CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; -import { CatalogCategory, CatalogEntity } from "../catalog"; -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; +import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity"; interface GeneralEntitySpec extends CatalogEntitySpec { path: string; @@ -23,18 +22,6 @@ export class GeneralEntity extends CatalogEntity(this) - ?.emit("contextMenuOpen", this, context); } } -class KubernetesClusterCategory extends CatalogCategory { +export class KubernetesClusterCategory extends CatalogCategory { public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; public readonly kind = "CatalogCategory"; public metadata = { @@ -145,17 +144,10 @@ class KubernetesClusterCategory extends CatalogCategory { public spec: CatalogCategorySpec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: KubernetesCluster, - }, + categoryVersion("v1alpha1", KubernetesCluster as CatalogEntityConstructor), ], names: { kind: "KubernetesCluster", }, }; } - -export const kubernetesClusterCategory = new KubernetesClusterCategory(); - -catalogCategoryRegistry.add(kubernetesClusterCategory); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 15331cc0fc..35dc86be57 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -4,8 +4,7 @@ */ import type { CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { CatalogCategory, CatalogEntity } from "../catalog"; -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; +import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; @@ -30,11 +29,7 @@ export class WebLink extends CatalogEntity(this) - ?.emit("contextMenuOpen", this, context); } } @@ -62,15 +53,10 @@ export class WebLinkCategory extends CatalogCategory { public spec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: WebLink, - }, + categoryVersion("v1alpha1", WebLink), ], names: { kind: "WebLink", }, }; } - -catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/catalog/catalog-category-registry.ts b/src/common/catalog/catalog-category-registry.ts index 7eb3e0fac8..d456f38f06 100644 --- a/src/common/catalog/catalog-category-registry.ts +++ b/src/common/catalog/catalog-category-registry.ts @@ -3,96 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, computed, observable, makeObservable } from "mobx"; -import { once } from "lodash"; -import { iter, getOrInsertMap, strictSet } from "../utils"; -import type { Disposer } from "../utils"; -import type { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; -export type CategoryFilter = (category: CatalogCategory) => any; - -export class CatalogCategoryRegistry { - protected categories = observable.set(); - protected groupKinds = new Map>(); - protected filters = observable.set([], { - deep: false, - }); - - constructor() { - makeObservable(this); - } - - @action add(category: CatalogCategory): Disposer { - const byGroup = getOrInsertMap(this.groupKinds, category.spec.group); - - this.categories.add(category); - strictSet(byGroup, category.spec.names.kind, category); - - return () => { - this.categories.delete(category); - byGroup.delete(category.spec.names.kind); - }; - } - - @computed get items() { - return Array.from(this.categories); - } - - @computed get filteredItems() { - return Array.from( - iter.reduce( - this.filters, - iter.filter, - this.items.values(), - ), - ); - } - - - getForGroupKind(group: string, kind: string): T | undefined { - return this.groupKinds.get(group)?.get(kind) as T; - } - - getEntityForData(data: CatalogEntityData & CatalogEntityKindData) { - const category = this.getCategoryForEntity(data); - - if (!category) { - return null; - } - - const splitApiVersion = data.apiVersion.split("/"); - const version = splitApiVersion[1]; - - const specVersion = category.spec.versions.find((v) => v.name === version); - - if (!specVersion) { - return null; - } - - return new specVersion.entityClass(data); - } - - getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData): T | undefined { - const splitApiVersion = data.apiVersion.split("/"); - const group = splitApiVersion[0]; - - return this.getForGroupKind(group, data.kind); - } - - getByName(name: string) { - return this.items.find(category => category.metadata?.name == name); - } - - /** - * Add a new filter to the set of category filters - * @param fn The function that should return a truthy value if that category should be displayed - * @returns A function to remove that filter - */ - addCatalogCategoryFilter(fn: CategoryFilter): Disposer { - this.filters.add(fn); - - return once(() => void this.filters.delete(fn)); - } -} - -export const catalogCategoryRegistry = new CatalogCategoryRegistry(); +/** + * @deprecated use `di.inject(catalogCategoryRegistryInjectable)` instead + */ +export const catalogCategoryRegistry = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable); diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index c8b905ffab..7effe60f2f 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -11,19 +11,19 @@ import type { Disposer } from "../utils"; import { iter } from "../utils"; import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns"; -type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; -type ExtractEntityStatusType = Entity extends CatalogEntity ? Status : never; -type ExtractEntitySpecType = Entity extends CatalogEntity ? Spec : never; +export type CatalogEntityDataFor = Entity extends CatalogEntity + ? CatalogEntityData + : never; + +export type CatalogEntityInstanceFrom = Constructor extends CatalogEntityConstructor + ? Entity + : never; export type CatalogEntityConstructor = ( - (new (data: CatalogEntityData< - ExtractEntityMetadataType, - ExtractEntityStatusType, - ExtractEntitySpecType - >) => Entity) + new (data: CatalogEntityDataFor) => Entity ); -export interface CatalogCategoryVersion { +export interface CatalogCategoryVersion { /** * The specific version that the associated constructor is for. This MUST be * a DNS label and SHOULD be of the form `vN`, `vNalphaY`, or `vNbetaY` where @@ -35,19 +35,19 @@ export interface CatalogCategoryVersion { * - `v1alpha2` * - `v3beta2` */ - name: string; + readonly name: string; /** * The constructor for the entities. */ - entityClass: CatalogEntityConstructor; + readonly entityClass: CatalogEntityConstructor; } export interface CatalogCategorySpec { /** * The grouping for for the category. This MUST be a DNS label. */ - group: string; + readonly group: string; /** * The specific versions of the constructors. @@ -56,18 +56,18 @@ export interface CatalogCategorySpec { * For example, if `group = "entity.k8slens.dev"` and there is an entry in `.versions` with * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1` */ - versions: CatalogCategoryVersion[]; + readonly versions: CatalogCategoryVersion[]; /** * This is the concerning the category */ - names: { + readonly names: { /** * The kind of entity that this category is for. This value MUST be a DNS * label and MUST be equal to the `kind` fields that are produced by the * `.versions.[] | .entityClass` fields. */ - kind: string; + readonly kind: string; }; /** @@ -81,7 +81,7 @@ export interface CatalogCategorySpec { * * These columns will not be used in the "Browse" view. */ - displayColumns?: CategoryColumnRegistration[]; + readonly displayColumns?: CategoryColumnRegistration[]; } /** @@ -109,6 +109,30 @@ export interface CatalogCategoryEvents { contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; } +export interface CatalogCategoryMetadata { + /** + * The name of your category. The category can be searched for by this + * value. This will also be used for the catalog menu. + */ + readonly name: string; + /** + * Either an `` or the name of an icon from {@link IconProps} + */ + readonly icon: string; +} + +export function categoryVersion< + T extends CatalogEntity, + Metadata extends CatalogEntityMetadata, + Status extends CatalogEntityStatus, + Spec extends CatalogEntitySpec, +>(name: string, entityClass: new (data: CatalogEntityData) => T): CatalogCategoryVersion { + return { + name, + entityClass: entityClass as CatalogEntityConstructor, + }; +} + export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter) { /** * The version of category that you are wanting to declare. @@ -131,28 +155,17 @@ export abstract class CatalogCategory extends (EventEmitter as new () => TypedEm /** * The data about the category itself */ - abstract readonly metadata: { - /** - * The name of your category. The category can be searched for by this - * value. This will also be used for the catalog menu. - */ - name: string; - - /** - * Either an `` or the name of an icon from {@link IconProps} - */ - icon: string; - }; + abstract readonly metadata: CatalogCategoryMetadata; /** * The most important part of a category, as it is where entity versions are declared. */ - abstract spec: CatalogCategorySpec; + abstract readonly spec: CatalogCategorySpec; /** * @internal */ - protected filters = observable.set([], { + protected readonly filters = observable.set([], { deep: false, }); @@ -217,14 +230,16 @@ export abstract class CatalogCategory extends (EventEmitter as new () => TypedEm } } -export interface CatalogEntityMetadata { +export type EntityMetadataObject = { [Key in string]?: EntityMetadataValue }; +export type EntityMetadataValue = string | number | boolean | EntityMetadataObject | undefined; + +export interface CatalogEntityMetadata extends EntityMetadataObject { uid: string; name: string; shortName?: string; description?: string; source?: string; labels: Record; - [key: string]: string | object; } export interface CatalogEntityStatus { @@ -392,7 +407,7 @@ export abstract class CatalogEntity< return this.status.enabled ?? true; } - public abstract onRun?(context: CatalogEntityActionContext): void | Promise; - public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; - public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; + public onRun?(context: CatalogEntityActionContext): void | Promise; + public onContextMenuOpen?(context: CatalogEntityContextMenuContext): void | Promise; + public onSettingsOpen?(context: CatalogEntitySettingsContext): void | Promise; } diff --git a/src/common/catalog/categories/general.injectable.ts b/src/common/catalog/categories/general.injectable.ts new file mode 100644 index 0000000000..d2e3ba7a69 --- /dev/null +++ b/src/common/catalog/categories/general.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { GeneralCategory } from "../../catalog-entities"; +import { builtInCategoryInjectionToken } from "../category-registry.injectable"; + +const generalCategoryInjectable = getInjectable({ + id: "general-category", + instantiate: () => new GeneralCategory(), + injectionToken: builtInCategoryInjectionToken, +}); + +export default generalCategoryInjectable; diff --git a/src/common/catalog/categories/kubernetes-cluster.injectable.ts b/src/common/catalog/categories/kubernetes-cluster.injectable.ts new file mode 100644 index 0000000000..6dc3f9be8d --- /dev/null +++ b/src/common/catalog/categories/kubernetes-cluster.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { KubernetesClusterCategory } from "../../catalog-entities/kubernetes-cluster"; +import { builtInCategoryInjectionToken } from "../category-registry.injectable"; + +const kubernetesClusterCategoryInjectable = getInjectable({ + id: "kubernetes-cluster-category", + instantiate: () => new KubernetesClusterCategory(), + injectionToken: builtInCategoryInjectionToken, +}); + +export default kubernetesClusterCategoryInjectable; diff --git a/src/common/catalog/categories/weblink.injectable.ts b/src/common/catalog/categories/weblink.injectable.ts new file mode 100644 index 0000000000..339758efbf --- /dev/null +++ b/src/common/catalog/categories/weblink.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { WebLinkCategory } from "../../catalog-entities"; +import { builtInCategoryInjectionToken } from "../category-registry.injectable"; + +const weblinkCategoryInjectable = getInjectable({ + id: "weblink-category", + instantiate: () => new WebLinkCategory(), + injectionToken: builtInCategoryInjectionToken, +}); + +export default weblinkCategoryInjectable; diff --git a/src/common/catalog/category-registry.injectable.ts b/src/common/catalog/category-registry.injectable.ts new file mode 100644 index 0000000000..a73ed973f6 --- /dev/null +++ b/src/common/catalog/category-registry.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { CatalogCategory } from "./catalog-entity"; +import { CatalogCategoryRegistry } from "./category-registry"; + +export const builtInCategoryInjectionToken = getInjectionToken({ + id: "built-in-category-token", +}); + +const catalogCategoryRegistryInjectable = getInjectable({ + id: "catalog-category-registry", + instantiate: (di) => { + const registry = new CatalogCategoryRegistry(); + const categories = di.injectMany(builtInCategoryInjectionToken); + + for (const category of categories) { + registry.add(category); + } + + return registry; + }, +}); + +export default catalogCategoryRegistryInjectable; diff --git a/src/common/catalog/category-registry.ts b/src/common/catalog/category-registry.ts new file mode 100644 index 0000000000..75c33d8a10 --- /dev/null +++ b/src/common/catalog/category-registry.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, computed, observable, makeObservable } from "mobx"; +import { once } from "lodash"; +import { iter, getOrInsertMap, strictSet } from "../utils"; +import type { Disposer } from "../utils"; +import type { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; + +export type CategoryFilter = (category: CatalogCategory) => any; + +export class CatalogCategoryRegistry { + protected readonly categories = observable.set(); + protected readonly groupKinds = new Map>(); + protected readonly filters = observable.set([], { + deep: false, + }); + + constructor() { + makeObservable(this); + } + + @action add(category: CatalogCategory): Disposer { + const byGroup = getOrInsertMap(this.groupKinds, category.spec.group); + + this.categories.add(category); + strictSet(byGroup, category.spec.names.kind, category); + + return () => { + this.categories.delete(category); + byGroup.delete(category.spec.names.kind); + }; + } + + @computed get items() { + return Array.from(this.categories); + } + + @computed get filteredItems() { + return Array.from( + iter.reduce( + this.filters, + iter.filter, + this.items.values(), + ), + ); + } + + + getForGroupKind(group: string, kind: string): T | undefined { + return this.groupKinds.get(group)?.get(kind) as T; + } + + getEntityForData(data: CatalogEntityData & CatalogEntityKindData) { + const category = this.getCategoryForEntity(data); + + if (!category) { + return null; + } + + const splitApiVersion = data.apiVersion.split("/"); + const version = splitApiVersion[1]; + + const specVersion = category.spec.versions.find((v) => v.name === version); + + if (!specVersion) { + return null; + } + + return new specVersion.entityClass(data); + } + + hasCategoryForEntity({ kind, apiVersion }: CatalogEntityData & CatalogEntityKindData): boolean { + const splitApiVersion = apiVersion.split("/"); + const group = splitApiVersion[0]; + + return this.groupKinds.get(group)?.has(kind) ?? false; + } + + getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData): T | undefined { + const splitApiVersion = data.apiVersion.split("/"); + const group = splitApiVersion[0]; + + return this.getForGroupKind(group, data.kind); + } + + getByName(name: string) { + return this.items.find(category => category.metadata?.name == name); + } + + /** + * Add a new filter to the set of category filters + * @param fn The function that should return a truthy value if that category should be displayed + * @returns A function to remove that filter + */ + addCatalogCategoryFilter(fn: CategoryFilter): Disposer { + this.filters.add(fn); + + return once(() => void this.filters.delete(fn)); + } +} diff --git a/src/common/catalog/has-category-for-entity.injectable.ts b/src/common/catalog/has-category-for-entity.injectable.ts new file mode 100644 index 0000000000..cff7d720c1 --- /dev/null +++ b/src/common/catalog/has-category-for-entity.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +export type HasCategoryForEntity = (data: CatalogEntityData & CatalogEntityKindData) => boolean; + +const hasCategoryForEntityInjectable = getInjectable({ + id: "has-category-for-entity", + + instantiate: (di): HasCategoryForEntity => { + const registry = di.inject(catalogCategoryRegistryInjectable); + + return (data) => registry.hasCategoryForEntity(data); + }, +}); + +export default hasCategoryForEntityInjectable; diff --git a/src/common/catalog/index.ts b/src/common/catalog/index.ts index 2e897fff01..a5c5ec4276 100644 --- a/src/common/catalog/index.ts +++ b/src/common/catalog/index.ts @@ -4,4 +4,5 @@ */ export * from "./catalog-category-registry"; +export * from "./category-registry"; export * from "./catalog-entity"; diff --git a/src/common/catalog/visit-entity-context-menu.injectable.ts b/src/common/catalog/visit-entity-context-menu.injectable.ts new file mode 100644 index 0000000000..eb1a2abeba --- /dev/null +++ b/src/common/catalog/visit-entity-context-menu.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogEntity, CatalogEntityContextMenuContext } from "./catalog-entity"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +export type VisitEntityContextMenu = (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; + +const visitEntityContextMenuInjectable = getInjectable({ + id: "visit-entity-context-menu", + instantiate: (di): VisitEntityContextMenu => { + const categoryRegistry = di.inject(catalogCategoryRegistryInjectable); + + return (entity, context) => { + entity.onContextMenuOpen?.(context); + categoryRegistry.getCategoryForEntity(entity)?.emit("contextMenuOpen", entity, context); + }; + }, +}); + +export default visitEntityContextMenuInjectable; diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets-store.injectable.ts b/src/common/cluster-frames.injectable.ts similarity index 53% rename from src/renderer/components/+workloads-daemonsets/daemonsets-store.injectable.ts rename to src/common/cluster-frames.injectable.ts index 044750d493..23897012a0 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets-store.injectable.ts +++ b/src/common/cluster-frames.injectable.ts @@ -3,13 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { daemonSetStore } from "./daemonsets.store"; +import { clusterFrameMap } from "./cluster-frames"; -const daemonsetsStoreInjectable = getInjectable({ - id: "daemonsets-store", - instantiate: () => daemonSetStore, +const clusterFramesInjectable = getInjectable({ + id: "cluster-frames", + instantiate: () => clusterFrameMap, causesSideEffects: true, }); -export default daemonsetsStoreInjectable; - +export default clusterFramesInjectable; diff --git a/src/common/cluster-store/allowed-resources.injectable.ts b/src/common/cluster-store/allowed-resources.injectable.ts index 2277befbd6..27ce2d510f 100644 --- a/src/common/cluster-store/allowed-resources.injectable.ts +++ b/src/common/cluster-store/allowed-resources.injectable.ts @@ -12,7 +12,7 @@ const allowedResourcesInjectable = getInjectable({ instantiate: (di) => { const cluster = di.inject(hostedClusterInjectable); - return computed(() => new Set(cluster.allowedResources), { + return computed(() => new Set(cluster?.allowedResources), { // This needs to be here so that during refresh changes are only propogated when necessary equals: (cur, prev) => comparer.structural(cur, prev), }); diff --git a/src/common/cluster-store/cluster-store.injectable.ts b/src/common/cluster-store/cluster-store.injectable.ts index a47978376a..58b540495a 100644 --- a/src/common/cluster-store/cluster-store.injectable.ts +++ b/src/common/cluster-store/cluster-store.injectable.ts @@ -9,10 +9,13 @@ import { createClusterInjectionToken } from "../cluster/create-cluster-injection const clusterStoreInjectable = getInjectable({ id: "cluster-store", - instantiate: (di) => - ClusterStore.createInstance({ + instantiate: (di) => { + ClusterStore.resetInstance(); + + return ClusterStore.createInstance({ createCluster: di.inject(createClusterInjectionToken), - }), + }); + }, causesSideEffects: true, }); diff --git a/src/common/cluster-store/cluster-store.ts b/src/common/cluster-store/cluster-store.ts index 901f340bda..55ebf6aad1 100644 --- a/src/common/cluster-store/cluster-store.ts +++ b/src/common/cluster-store/cluster-store.ts @@ -103,8 +103,12 @@ export class ClusterStore extends BaseStore { return this.clusters.size > 0; } - getById(id: ClusterId): Cluster | null { - return this.clusters.get(id) ?? null; + getById(id: ClusterId | undefined): Cluster | undefined { + if (id) { + return this.clusters.get(id); + } + + return undefined; } addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { diff --git a/src/common/cluster-store/hosted-cluster-id.injectable.ts b/src/common/cluster-store/hosted-cluster-id.injectable.ts new file mode 100644 index 0000000000..5a5c99c580 --- /dev/null +++ b/src/common/cluster-store/hosted-cluster-id.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getClusterIdFromHost } from "../utils"; + +const hostedClusterIdInjectable = getInjectable({ + id: "hosted-cluster-id", + instantiate: () => getClusterIdFromHost(location.host), + causesSideEffects: true, +}); + +export default hostedClusterIdInjectable; diff --git a/src/common/cluster-store/hosted-cluster.injectable.ts b/src/common/cluster-store/hosted-cluster.injectable.ts index 1bcafc9238..fa49cbca48 100644 --- a/src/common/cluster-store/hosted-cluster.injectable.ts +++ b/src/common/cluster-store/hosted-cluster.injectable.ts @@ -3,16 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { getHostedClusterId } from "../utils"; +import hostedClusterIdInjectable from "./hosted-cluster-id.injectable"; import clusterStoreInjectable from "./cluster-store.injectable"; const hostedClusterInjectable = getInjectable({ id: "hosted-cluster", instantiate: (di) => { - const hostedClusterId = getHostedClusterId(); + const hostedClusterId = di.inject(hostedClusterIdInjectable); + const store = di.inject(clusterStoreInjectable); - return di.inject(clusterStoreInjectable).getById(hostedClusterId); + return store.getById(hostedClusterId); }, }); diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index fbe7880f3c..1652e18ef1 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ipcMain } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; import { broadcastMessage } from "../ipc"; -import type { ContextHandler } from "../../main/context-handler/context-handler"; +import type { ClusterContextHandler } from "../../main/context-handler/context-handler"; import type { KubeConfig } from "@kubernetes/client-node"; import { HttpError } from "@kubernetes/client-node"; import type { Kubectl } from "../../main/kubectl/kubectl"; @@ -14,25 +13,29 @@ import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../kube-helpers"; import type { KubeApiResource, KubeResource } from "../rbac"; import { apiResourceRecord, apiResources } from "../rbac"; -import logger from "../../main/logger"; -import { VersionDetector } from "../../main/cluster-detectors/version-detector"; -import { DetectorRegistry } from "../../main/cluster-detectors/detector-registry"; +import type { VersionDetector } from "../../main/cluster-detectors/version-detector"; +import type { DetectorRegistry } from "../../main/cluster-detectors/detector-registry"; import plimit from "p-limit"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types"; -import { disposer, toJS } from "../utils"; +import { disposer, isDefined, isRequestError, toJS } from "../utils"; import type { Response } from "request"; import { clusterListNamespaceForbiddenChannel } from "../ipc/cluster"; import type { CanI } from "./authorization-review.injectable"; import type { ListNamespaces } from "./list-namespaces.injectable"; +import assert from "assert"; +import type { Logger } from "../logger"; export interface ClusterDependencies { readonly directoryForKubeConfigs: string; + readonly logger: Logger; + readonly detectorRegistry: DetectorRegistry; createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; - createContextHandler: (cluster: Cluster) => ContextHandler; + createContextHandler: (cluster: Cluster) => ClusterContextHandler; createKubectl: (clusterVersion: string) => Kubectl; createAuthorizationReview: (config: KubeConfig) => CanI; createListNamespaces: (config: KubeConfig) => ListNamespaces; + createVersionDetector: (cluster: Cluster) => VersionDetector; } /** @@ -43,17 +46,31 @@ export interface ClusterDependencies { export class Cluster implements ClusterModel, ClusterState { /** Unique id for a cluster */ public readonly id: ClusterId; - private kubeCtl: Kubectl; + private kubeCtl: Kubectl | undefined; /** * Context handler * * @internal */ - public contextHandler: ContextHandler; - protected proxyKubeconfigManager: KubeconfigManager; - protected eventsDisposer = disposer(); + protected readonly _contextHandler: ClusterContextHandler | undefined; + protected readonly _proxyKubeconfigManager: KubeconfigManager | undefined; + protected readonly eventsDisposer = disposer(); protected activated = false; - private resourceAccessStatuses: Map = new Map(); + private readonly resourceAccessStatuses = new Map(); + + public get contextHandler() { + // TODO: remove these once main/renderer are seperate classes + assert(this._contextHandler, "contextHandler is only defined in the main environment"); + + return this._contextHandler; + } + + protected get proxyKubeconfigManager() { + // TODO: remove these once main/renderer are seperate classes + assert(this._proxyKubeconfigManager, "proxyKubeconfigManager is only defined in the main environment"); + + return this._proxyKubeconfigManager; + } get whenReady() { return when(() => this.ready); @@ -64,21 +81,21 @@ export class Cluster implements ClusterModel, ClusterState { * * @observable */ - @observable contextName: string; + @observable contextName!: string; /** * Path to kubeconfig * * @observable */ - @observable kubeConfigPath: string; + @observable kubeConfigPath!: string; /** * @deprecated */ - @observable workspace: string; + @observable workspace?: string; /** * @deprecated */ - @observable workspaces: string[]; + @observable workspaces?: string[]; /** * Kubernetes API server URL * @@ -215,7 +232,7 @@ export class Cluster implements ClusterModel, ClusterState { * @computed * @internal */ - @computed get defaultNamespace(): string { + @computed get defaultNamespace(): string | undefined { return this.preferences.defaultNamespace; } @@ -231,19 +248,24 @@ export class Cluster implements ClusterModel, ClusterState { throw validationError; } - this.apiUrl = config.getCluster(config.getContextObject(this.contextName).cluster).server; + const context = config.getContextObject(this.contextName); - if (ipcMain) { - // for the time being, until renderer gets its own cluster type - this.contextHandler = this.dependencies.createContextHandler(this); - this.proxyKubeconfigManager = this.dependencies.createKubeconfigManager(this); + assert(context); - logger.debug(`[CLUSTER]: Cluster init success`, { - id: this.id, - context: this.contextName, - apiUrl: this.apiUrl, - }); - } + const cluster = config.getCluster(context.cluster); + + assert(cluster); + + this.apiUrl = cluster.server; + + // for the time being, until renderer gets its own cluster type + this._contextHandler = this.dependencies.createContextHandler(this); + this._proxyKubeconfigManager = this.dependencies.createKubeconfigManager(this); + this.dependencies.logger.debug(`[CLUSTER]: Cluster init success`, { + id: this.id, + context: this.contextName, + apiUrl: this.apiUrl, + }); } /** @@ -255,6 +277,7 @@ export class Cluster implements ClusterModel, ClusterState { // Note: do not assign ID as that should never be updated this.kubeConfigPath = model.kubeConfigPath; + this.contextName = model.contextName; if (model.workspace) { this.workspace = model.workspace; @@ -264,10 +287,6 @@ export class Cluster implements ClusterModel, ClusterState { this.workspaces = model.workspaces; } - if (model.contextName) { - this.contextName = model.contextName; - } - if (model.preferences) { this.preferences = model.preferences; } @@ -289,7 +308,7 @@ export class Cluster implements ClusterModel, ClusterState { * @internal */ protected bindEvents() { - logger.info(`[CLUSTER]: bind events`, this.getMeta()); + this.dependencies.logger.info(`[CLUSTER]: bind events`, this.getMeta()); const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000); // every 15 minutes @@ -310,13 +329,13 @@ export class Cluster implements ClusterModel, ClusterState { * @internal */ protected async recreateProxyKubeconfig() { - logger.info("[CLUSTER]: Recreating proxy kubeconfig"); + this.dependencies.logger.info("[CLUSTER]: Recreating proxy kubeconfig"); try { await this.proxyKubeconfigManager.clear(); await this.getProxyKubeconfig(); } catch (error) { - logger.error(`[CLUSTER]: failed to recreate proxy kubeconfig`, error); + this.dependencies.logger.error(`[CLUSTER]: failed to recreate proxy kubeconfig`, error); } } @@ -330,7 +349,7 @@ export class Cluster implements ClusterModel, ClusterState { return this.pushState(); } - logger.info(`[CLUSTER]: activate`, this.getMeta()); + this.dependencies.logger.info(`[CLUSTER]: activate`, this.getMeta()); if (!this.eventsDisposer.length) { this.bindEvents(); @@ -348,7 +367,7 @@ export class Cluster implements ClusterModel, ClusterState { await this.refreshAccessibility(); // download kubectl in background, so it's not blocking dashboard this.ensureKubectl() - .catch(error => logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error)); + .catch(error => this.dependencies.logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error)); this.broadcastConnectUpdate("Connected, waiting for view to load ..."); } @@ -372,9 +391,8 @@ export class Cluster implements ClusterModel, ClusterState { */ @action async reconnect() { - logger.info(`[CLUSTER]: reconnect`, this.getMeta()); - this.contextHandler?.stopServer(); - await this.contextHandler?.ensureServer(); + this.dependencies.logger.info(`[CLUSTER]: reconnect`, this.getMeta()); + await this.contextHandler?.restartServer(); this.disconnected = false; } @@ -383,10 +401,10 @@ export class Cluster implements ClusterModel, ClusterState { */ @action disconnect(): void { if (this.disconnected) { - return void logger.debug("[CLUSTER]: already disconnected", { id: this.id }); + return void this.dependencies.logger.debug("[CLUSTER]: already disconnected", { id: this.id }); } - logger.info(`[CLUSTER]: disconnecting`, { id: this.id }); + this.dependencies.logger.info(`[CLUSTER]: disconnecting`, { id: this.id }); this.eventsDisposer(); this.contextHandler?.stopServer(); this.disconnected = true; @@ -397,7 +415,7 @@ export class Cluster implements ClusterModel, ClusterState { this.allowedNamespaces = []; this.resourceAccessStatuses.clear(); this.pushState(); - logger.info(`[CLUSTER]: disconnected`, { id: this.id }); + this.dependencies.logger.info(`[CLUSTER]: disconnected`, { id: this.id }); } /** @@ -406,7 +424,7 @@ export class Cluster implements ClusterModel, ClusterState { */ @action async refresh(opts: ClusterRefreshOptions = {}) { - logger.info(`[CLUSTER]: refresh`, this.getMeta()); + this.dependencies.logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.refreshConnectionStatus(); if (this.accessible) { @@ -424,8 +442,8 @@ export class Cluster implements ClusterModel, ClusterState { */ @action async refreshMetadata() { - logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); - const metadata = await DetectorRegistry.getInstance().detectForCluster(this); + this.dependencies.logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); + const metadata = await this.dependencies.detectorRegistry.detectForCluster(this); const existingMetadata = this.metadata; this.metadata = Object.assign(existingMetadata, metadata); @@ -488,41 +506,49 @@ export class Cluster implements ClusterModel, ClusterState { protected async getConnectionStatus(): Promise { try { - const versionDetector = new VersionDetector(this); + const versionDetector = this.dependencies.createVersionDetector(this); const versionData = await versionDetector.detect(); this.metadata.version = versionData.value; return ClusterStatus.AccessGranted; } catch (error) { - logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`); + this.dependencies.logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`); - if (error.statusCode) { - if (error.statusCode >= 400 && error.statusCode < 500) { - this.broadcastConnectUpdate("Invalid credentials", true); + if (isRequestError(error)) { + if (error.statusCode) { + if (error.statusCode >= 400 && error.statusCode < 500) { + this.broadcastConnectUpdate("Invalid credentials", true); - return ClusterStatus.AccessDenied; - } + return ClusterStatus.AccessDenied; + } - this.broadcastConnectUpdate(error.error || error.message, true); + const message = String(error.error || error.message) || String(error); - return ClusterStatus.Offline; - } - - if (error.failed === true) { - if (error.timedOut === true) { - this.broadcastConnectUpdate("Connection timed out", true); + this.broadcastConnectUpdate(message, true); return ClusterStatus.Offline; } - this.broadcastConnectUpdate("Failed to fetch credentials", true); + if (error.failed === true) { + if (error.timedOut === true) { + this.broadcastConnectUpdate("Connection timed out", true); - return ClusterStatus.AccessDenied; + return ClusterStatus.Offline; + } + + this.broadcastConnectUpdate("Failed to fetch credentials", true); + + return ClusterStatus.AccessDenied; + } + + const message = String(error.error || error.message) || String(error); + + this.broadcastConnectUpdate(message, true); + } else { + this.broadcastConnectUpdate("Unknown error has occurred", true); } - this.broadcastConnectUpdate(error.message, true); - return ClusterStatus.Offline; } } @@ -571,7 +597,7 @@ export class Cluster implements ClusterModel, ClusterState { * @param state cluster state */ pushState(state = this.getState()) { - logger.silly(`[CLUSTER]: push-state`, state); + this.dependencies.logger.silly(`[CLUSTER]: push-state`, state); broadcastMessage("cluster:state", this.id, state); } @@ -594,7 +620,7 @@ export class Cluster implements ClusterModel, ClusterState { broadcastConnectUpdate(message: string, isError = false): void { const update: KubeAuthUpdate = { message, isError }; - logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); + this.dependencies.logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); broadcastMessage(`cluster:${this.id}:connection-update`, update); } @@ -609,12 +635,12 @@ export class Cluster implements ClusterModel, ClusterState { return await listNamespaces(); } catch (error) { const ctx = proxyConfig.getContextObject(this.contextName); - const namespaceList = [ctx.namespace].filter(Boolean); + const namespaceList = [ctx?.namespace].filter(isDefined); if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) { const { response } = error as HttpError & { response: Response }; - logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.id, error: response.body }); + this.dependencies.logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.id, error: response.body }); broadcastMessage(clusterListNamespaceForbiddenChannel, this.id); } diff --git a/src/common/cluster/create-cluster-injection-token.ts b/src/common/cluster/create-cluster-injection-token.ts index 0508e7b325..f1e8ef9757 100644 --- a/src/common/cluster/create-cluster-injection-token.ts +++ b/src/common/cluster/create-cluster-injection-token.ts @@ -6,5 +6,8 @@ import { getInjectionToken } from "@ogre-tools/injectable"; import type { ClusterModel } from "../cluster-types"; import type { Cluster } from "./cluster"; -export const createClusterInjectionToken = - getInjectionToken<(model: ClusterModel) => Cluster>({ id: "create-cluster-token" }); +export type CreateCluster = (model: ClusterModel) => Cluster; + +export const createClusterInjectionToken = getInjectionToken({ + id: "create-cluster-token", +}); diff --git a/src/common/cluster/list-namespaces.injectable.ts b/src/common/cluster/list-namespaces.injectable.ts index 200326c705..468ff3ac2e 100644 --- a/src/common/cluster/list-namespaces.injectable.ts +++ b/src/common/cluster/list-namespaces.injectable.ts @@ -5,6 +5,7 @@ import type { KubeConfig } from "@kubernetes/client-node"; import { CoreV1Api } from "@kubernetes/client-node"; import { getInjectable } from "@ogre-tools/injectable"; +import { isDefined } from "../utils"; export type ListNamespaces = () => Promise; @@ -14,7 +15,9 @@ export function listNamespaces(config: KubeConfig): ListNamespaces { return async () => { const { body: { items }} = await coreApi.listNamespace(); - return items.map(ns => ns.metadata.name); + return items + .map(ns => ns.metadata?.name) + .filter(isDefined); }; } diff --git a/src/common/front-end-routing/app-navigation-channel.injectable.ts b/src/common/front-end-routing/app-navigation-channel.injectable.ts new file mode 100644 index 0000000000..869fbfdecd --- /dev/null +++ b/src/common/front-end-routing/app-navigation-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type AppNavigationChannel = MessageChannel; + +const appNavigationChannelInjectable = getInjectable({ + id: "app-navigation-channel", + + instantiate: (): AppNavigationChannel => ({ + id: IpcRendererNavigationEvents.NAVIGATE_IN_APP, + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default appNavigationChannelInjectable; diff --git a/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts b/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts new file mode 100644 index 0000000000..596bd6d351 --- /dev/null +++ b/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type ClusterFrameNavigationChannel = MessageChannel; + +const clusterFrameNavigationChannelInjectable = getInjectable({ + id: "cluster-frame-navigation-channel", + + instantiate: (): ClusterFrameNavigationChannel => ({ + id: IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default clusterFrameNavigationChannelInjectable; diff --git a/src/common/front-end-routing/navigation-ipc-channel.ts b/src/common/front-end-routing/navigation-ipc-channel.ts deleted file mode 100644 index 6094664f81..0000000000 --- a/src/common/front-end-routing/navigation-ipc-channel.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { createChannel } from "../ipc-channel/create-channel/create-channel"; -import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; - -export const appNavigationIpcChannel = createChannel(IpcRendererNavigationEvents.NAVIGATE_IN_APP); -export const clusterFrameNavigationIpcChannel = createChannel(IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER); diff --git a/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts b/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts index 7cf871f9b9..369fb6eb8c 100644 --- a/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts +++ b/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts @@ -11,15 +11,12 @@ import type { ClusterStore } from "../cluster-store/cluster-store"; import { pipeline } from "@ogre-tools/fp"; describe("verify-that-all-routes-have-component", () => { - it("verify that routes have route component", async () => { + it("verify that routes have route component", () => { const rendererDi = getDiForUnitTesting({ doGeneralOverrides: true }); - rendererDi.override( - clusterStoreInjectable, - () => ({ getById: (): null => null } as unknown as ClusterStore), - ); - - await rendererDi.runSetups(); + rendererDi.override(clusterStoreInjectable, () => ({ + getById: () => null, + } as unknown as ClusterStore)); const routes = rendererDi.injectMany(routeInjectionToken); const routeComponents = rendererDi.injectMany( diff --git a/src/common/fs/ensure-dir.injectable.ts b/src/common/fs/ensure-dir.injectable.ts new file mode 100644 index 0000000000..88410ceee2 --- /dev/null +++ b/src/common/fs/ensure-dir.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +const ensureDirInjectable = getInjectable({ + id: "ensure-dir", + + // TODO: Remove usages of ensureDir from business logic. + // TODO: Read, Write, Watch etc. operations should do this internally. + instantiate: (di) => di.inject(fsInjectable).ensureDir, + + causesSideEffects: true, +}); + +export default ensureDirInjectable; diff --git a/src/common/get-configuration-file-model/app-version/app-version.injectable.ts b/src/common/get-configuration-file-model/app-version/app-version.injectable.ts index 0fe3142332..5fdfd30eba 100644 --- a/src/common/get-configuration-file-model/app-version/app-version.injectable.ts +++ b/src/common/get-configuration-file-model/app-version/app-version.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import packageInfo from "../../../../package.json"; +import packageJsonInjectable from "../../vars/package-json.injectable"; const appVersionInjectable = getInjectable({ id: "app-version", - instantiate: () => packageInfo.version, - causesSideEffects: true, + instantiate: (di) => di.inject(packageJsonInjectable).version, }); export default appVersionInjectable; diff --git a/src/common/hotbars/add-hotbar.injectable.ts b/src/common/hotbars/add-hotbar.injectable.ts new file mode 100644 index 0000000000..25ee0f588a --- /dev/null +++ b/src/common/hotbars/add-hotbar.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "./store.injectable"; +import type { CreateHotbarData, CreateHotbarOptions } from "./types"; + +export type AddHotbar = (data: CreateHotbarData, opts?: CreateHotbarOptions) => void; + +const addHotbarInjectable = getInjectable({ + id: "add-hotbar", + instantiate: (di): AddHotbar => { + const store = di.inject(hotbarStoreInjectable); + + return (data, opts) => store.add(data, opts); + }, +}); + +export default addHotbarInjectable; diff --git a/src/common/hotbar-store.injectable.ts b/src/common/hotbars/store.injectable.ts similarity index 65% rename from src/common/hotbar-store.injectable.ts rename to src/common/hotbars/store.injectable.ts index e8a883cf0a..ace13b8be4 100644 --- a/src/common/hotbar-store.injectable.ts +++ b/src/common/hotbars/store.injectable.ts @@ -3,8 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import catalogCatalogEntityInjectable from "./catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; -import { HotbarStore } from "./hotbar-store"; +import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { HotbarStore } from "./store"; +import loggerInjectable from "../logger.injectable"; const hotbarStoreInjectable = getInjectable({ id: "hotbar-store", @@ -14,6 +15,7 @@ const hotbarStoreInjectable = getInjectable({ return HotbarStore.createInstance({ catalogCatalogEntity: di.inject(catalogCatalogEntityInjectable), + logger: di.inject(loggerInjectable), }); }, diff --git a/src/common/hotbar-store.ts b/src/common/hotbars/store.ts similarity index 81% rename from src/common/hotbar-store.ts rename to src/common/hotbars/store.ts index abbae0e1b0..a75182b23b 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbars/store.ts @@ -4,22 +4,17 @@ */ import { action, comparer, observable, makeObservable, computed } from "mobx"; -import { BaseStore } from "./base-store"; -import migrations from "../migrations/hotbar-store"; -import { toJS } from "./utils"; -import type { CatalogEntity } from "./catalog"; -import logger from "../main/logger"; -import { broadcastMessage } from "./ipc"; -import type { - Hotbar, - CreateHotbarData, - CreateHotbarOptions } from "./hotbar-types"; -import { - defaultHotbarCells, - getEmptyHotbar, -} from "./hotbar-types"; -import { hotbarTooManyItemsChannel } from "./ipc/hotbar"; -import type { GeneralEntity } from "./catalog-entities"; +import { BaseStore } from "../base-store"; +import migrations from "../../migrations/hotbar-store"; +import { toJS } from "../utils"; +import type { CatalogEntity } from "../catalog"; +import { broadcastMessage } from "../ipc"; +import type { Hotbar, CreateHotbarData, CreateHotbarOptions } from "./types"; +import { defaultHotbarCells, getEmptyHotbar } from "./types"; +import { hotbarTooManyItemsChannel } from "../ipc/hotbar"; +import type { GeneralEntity } from "../catalog-entities"; +import type { Logger } from "../logger"; +import assert from "assert"; export interface HotbarStoreModel { hotbars: Hotbar[]; @@ -27,15 +22,16 @@ export interface HotbarStoreModel { } interface Dependencies { - catalogCatalogEntity: GeneralEntity; + readonly catalogCatalogEntity: GeneralEntity; + readonly logger: Logger; } export class HotbarStore extends BaseStore { readonly displayName = "HotbarStore"; @observable hotbars: Hotbar[] = []; - @observable private _activeHotbarId: string; + @observable private _activeHotbarId!: string; - constructor(private dependencies: Dependencies) { + constructor(private readonly dependencies: Dependencies) { super({ configName: "lens-hotbar-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names @@ -44,8 +40,8 @@ export class HotbarStore extends BaseStore { }, migrations, }); + makeObservable(this); - this.load(); } @computed get activeHotbarId() { @@ -62,7 +58,7 @@ export class HotbarStore extends BaseStore { this._activeHotbarId = this.hotbars[hotbar].id; } } else if (typeof hotbar === "string") { - if (this.getById(hotbar)) { + if (this.findById(hotbar)) { this._activeHotbarId = hotbar; } } else { @@ -120,34 +116,35 @@ export class HotbarStore extends BaseStore { return toJS(model); } - getActive() { - return this.getById(this.activeHotbarId); + getActive(): Hotbar { + const hotbar = this.findById(this.activeHotbarId); + + assert(hotbar, "There MUST always be an active hotbar"); + + return hotbar; } - getByName(name: string) { + findByName(name: string) { return this.hotbars.find((hotbar) => hotbar.name === name); } - getById(id: string) { + findById(id: string) { return this.hotbars.find((hotbar) => hotbar.id === id); } - add = action( - ( - data: CreateHotbarData, - { setActive = false }: CreateHotbarOptions = {}, - ) => { - const hotbar = getEmptyHotbar(data.name, data.id); + @action + add(data: CreateHotbarData, { setActive = false }: CreateHotbarOptions = {}) { + const hotbar = getEmptyHotbar(data.name, data.id); - this.hotbars.push(hotbar); + this.hotbars.push(hotbar); - if (setActive) { - this._activeHotbarId = hotbar.id; - } - }, - ); + if (setActive) { + this._activeHotbarId = hotbar.id; + } + } - setHotbarName = action((id: string, name: string) => { + @action + setHotbarName(id: string, name: string): void { const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); if (index < 0) { @@ -158,19 +155,18 @@ export class HotbarStore extends BaseStore { } this.hotbars[index].name = name; - }); + } - remove = action((hotbar: Hotbar) => { - if (this.hotbars.length <= 1) { - throw new Error("Cannot remove the last hotbar"); - } + @action + remove(hotbar: Hotbar) { + assert(this.hotbars.length >= 2, "Cannot remove the last hotbar"); this.hotbars = this.hotbars.filter((h) => h !== hotbar); if (this.activeHotbarId === hotbar.id) { this.setActiveHotbar(0); } - }); + } @action addToHotbar(item: CatalogEntity, cellIndex?: number) { @@ -209,7 +205,7 @@ export class HotbarStore extends BaseStore { } else if (0 <= cellIndex && cellIndex < hotbar.items.length) { hotbar.items[cellIndex] = newItem; } else { - logger.error( + this.dependencies.logger.error( `[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range`, { entityId: uid, hotbarId: hotbar.id, cellIndex }, ); @@ -246,8 +242,9 @@ export class HotbarStore extends BaseStore { findClosestEmptyIndex(from: number, direction = 1) { let index = from; + const hotbar = this.getActive(); - while (this.getActive().items[index] != null) { + while (hotbar.items[index] != null) { index += direction; } @@ -314,11 +311,9 @@ export class HotbarStore extends BaseStore { return false; } - return ( - this.getActive().items.findIndex( - (item) => item?.entity.uid === entity.getId(), - ) >= 0 - ); + const indexInActiveHotbar = this.getActive().items.findIndex(item => item?.entity.uid === entity.getId()); + + return indexInActiveHotbar >= 0; } getDisplayLabel(hotbar: Hotbar): string { diff --git a/src/common/hotbar-types.ts b/src/common/hotbars/types.ts similarity index 90% rename from src/common/hotbar-types.ts rename to src/common/hotbars/types.ts index 34ccfaa74e..6370fe136d 100644 --- a/src/common/hotbar-types.ts +++ b/src/common/hotbars/types.ts @@ -4,13 +4,13 @@ */ import * as uuid from "uuid"; -import type { Tuple } from "./utils"; -import { tuple } from "./utils"; +import type { Tuple } from "../utils"; +import { tuple } from "../utils"; export interface HotbarItem { entity: { uid: string; - name?: string; + name: string; source?: string; }; params?: { diff --git a/src/common/ipc-channel/create-channel/create-channel.ts b/src/common/ipc-channel/create-channel/create-channel.ts deleted file mode 100644 index 2a18e3c145..0000000000 --- a/src/common/ipc-channel/create-channel/create-channel.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { Channel } from "../channel"; - -export const createChannel = (name: string): Channel => ({ - name, - _template: null, -}); diff --git a/src/common/ipc/__tests__/type-enforced-ipc.test.ts b/src/common/ipc/__tests__/type-enforced-ipc.test.ts index d2820ec231..bd456257cb 100644 --- a/src/common/ipc/__tests__/type-enforced-ipc.test.ts +++ b/src/common/ipc/__tests__/type-enforced-ipc.test.ts @@ -53,7 +53,7 @@ describe("type enforced ipc tests", () => { const source = new EventEmitter(); const listener = () => called += 1; const results = [true, false, true]; - const verifier = (args: unknown[]): args is [] => results.pop(); + const verifier = (args: unknown[]): args is [] => results.pop() ?? false; const channel = "foobar"; onCorrect({ source, listener, verifier, channel }); diff --git a/src/common/ipc/broadcast-message.injectable.ts b/src/common/ipc/broadcast-message.injectable.ts new file mode 100644 index 0000000000..9df36ac27a --- /dev/null +++ b/src/common/ipc/broadcast-message.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { broadcastMessage } from "./ipc"; + +const broadcastMessageInjectable = getInjectable({ + id: "broadcast-message", + instantiate: () => broadcastMessage, + causesSideEffects: true, +}); + +export default broadcastMessageInjectable; diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index 60ae46438e..bb60ce4f6c 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -5,5 +5,4 @@ export * from "./ipc"; export * from "./invalid-kubeconfig"; -export * from "./update-available"; export * from "./type-enforced-ipc"; diff --git a/src/common/ipc/update-available.ts b/src/common/ipc/update-available.ts deleted file mode 100644 index ed5b18b13d..0000000000 --- a/src/common/ipc/update-available.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { UpdateInfo } from "electron-updater"; - -export const UpdateAvailableChannel = "update-available"; -export const AutoUpdateChecking = "auto-update:checking"; -export const AutoUpdateNoUpdateAvailable = "auto-update:no-update"; -export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]"; - -export type UpdateAvailableFromMain = [backChannel: string, updateInfo: UpdateInfo]; - -export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain { - if (args.length !== 2) { - return false; - } - - if (typeof args[0] !== "string") { - return false; - } - - if (typeof args[1] !== "object" || args[1] === null) { - // TODO: improve this checking - return false; - } - - return true; -} - -export type BackchannelArg = { - doUpdate: false; -} | { - doUpdate: true; - now: boolean; -}; - -export type UpdateAvailableToBackchannel = [updateDecision: BackchannelArg]; - -export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel { - if (args.length !== 1) { - return false; - } - - if (typeof args[0] !== "object" || args[0] === null) { - // TODO: improve this checking - return false; - } - - return true; -} diff --git a/src/common/item.store.ts b/src/common/item.store.ts index 68ef6813b9..805137e95c 100644 --- a/src/common/item.store.ts +++ b/src/common/item.store.ts @@ -13,8 +13,6 @@ export interface ItemObject { } export abstract class ItemStore { - abstract loadAll(...args: any[]): Promise; - protected defaultSorting = (item: Item) => item.getName(); @observable failedLoading = false; @@ -44,8 +42,7 @@ export abstract class ItemStore { return this.items.length; } - getByName(name: string, ...args: any[]): Item; - getByName(name: string): Item { + getByName(name: string): Item | undefined { return this.items.find(item => item.getName() === name); } @@ -115,7 +112,6 @@ export abstract class ItemStore { } } - protected async loadItem(...args: any[]): Promise; @action protected async loadItem(request: () => Promise, sortItems = true) { const item = await Promise.resolve(request()).catch(() => null); @@ -133,9 +129,9 @@ export abstract class ItemStore { if (sortItems) items = this.sortItems(items); this.items.replace(items); } - - return item; } + + return item; } @action diff --git a/src/common/k8s-api/__tests__/api-manager.test.ts b/src/common/k8s-api/__tests__/api-manager.test.ts index 25d613ee1c..3e411b6584 100644 --- a/src/common/k8s-api/__tests__/api-manager.test.ts +++ b/src/common/k8s-api/__tests__/api-manager.test.ts @@ -3,19 +3,32 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ingressStore } from "../../../renderer/components/+network-ingresses/ingress.store"; -import { apiManager } from "../api-manager"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import type { ApiManager } from "../api-manager"; +import apiManagerInjectable from "../api-manager/manager.injectable"; import { KubeApi } from "../kube-api"; import { KubeObject } from "../kube-object"; +import { KubeObjectStore } from "../kube-object.store"; class TestApi extends KubeApi { - protected async checkPreferredVersion() { return; } } +class TestStore extends KubeObjectStore { + +} + describe("ApiManager", () => { + let apiManager: ApiManager; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + apiManager = di.inject(apiManagerInjectable); + }); + describe("registerApi", () => { it("re-register store if apiBase changed", async () => { const apiBase = "apis/v1/foo"; @@ -23,25 +36,27 @@ describe("ApiManager", () => { const kubeApi = new TestApi({ objectConstructor: KubeObject, apiBase, + kind: "foo", fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, }); + const kubeStore = new TestStore(kubeApi); apiManager.registerApi(apiBase, kubeApi); // Define to use test api for ingress store - Object.defineProperty(ingressStore, "api", { value: kubeApi }); - apiManager.registerStore(ingressStore, [kubeApi]); + Object.defineProperty(kubeStore, "api", { value: kubeApi }); + apiManager.registerStore(kubeStore, [kubeApi]); // Test that store is returned with original apiBase - expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); // Change apiBase similar as checkPreferredVersion does Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase }); apiManager.registerApi(fallbackApiBase, kubeApi); // Test that store is returned with new apiBase - expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); }); }); }); diff --git a/src/common/k8s-api/__tests__/crd.test.ts b/src/common/k8s-api/__tests__/crd.test.ts index 5d9e51afe1..e1538dfbe6 100644 --- a/src/common/k8s-api/__tests__/crd.test.ts +++ b/src/common/k8s-api/__tests__/crd.test.ts @@ -16,8 +16,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", versions: [ { name: "123", @@ -44,8 +51,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", versions: [ { name: "123", @@ -72,8 +86,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", versions: [ { name: "123", @@ -100,8 +121,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", version: "abc", versions: [ { @@ -129,6 +157,7 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { version: "abc", diff --git a/src/common/k8s-api/__tests__/deployment.api.test.ts b/src/common/k8s-api/__tests__/deployment.api.test.ts index 827e2c33bd..60ada4e4d1 100644 --- a/src/common/k8s-api/__tests__/deployment.api.test.ts +++ b/src/common/k8s-api/__tests__/deployment.api.test.ts @@ -3,31 +3,39 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { Deployment, DeploymentApi } from "../endpoints/deployment.api"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; +import type { DeploymentApi } from "../endpoints/deployment.api"; +import deploymentApiInjectable from "../endpoints/deployment.api.injectable"; import type { KubeJsonApi } from "../kube-json-api"; -class DeploymentApiTest extends DeploymentApi { - public setRequest(request: any) { - this.request = request; - } -} - describe("DeploymentApi", () => { + let deploymentApi: DeploymentApi; + let kubeJsonApi: jest.Mocked; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(storesAndApisCanBeCreatedInjectable, () => true); + kubeJsonApi = { + getResponse: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + del: jest.fn(), + } as never; + di.override(apiKubeInjectable, () => kubeJsonApi); + + deploymentApi = di.inject(deploymentApiInjectable); + }); + describe("scale", () => { - const requestMock = { - patch: () => ({}), - } as unknown as KubeJsonApi; - - const sub = new DeploymentApiTest({ objectConstructor: Deployment }); - - sub.setRequest(requestMock); - it("requests Kubernetes API with PATCH verb and correct amount of replicas", () => { - const patchSpy = jest.spyOn(requestMock, "patch"); + deploymentApi.scale({ namespace: "default", name: "deployment-1" }, 5); - sub.scale({ namespace: "default", name: "deployment-1" }, 5); - - expect(patchSpy).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/deployments/deployment-1/scale", { + expect(kubeJsonApi.patch).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/deployments/deployment-1/scale", { data: { spec: { replicas: 5, diff --git a/src/common/k8s-api/__tests__/endpoint.api.test.ts b/src/common/k8s-api/__tests__/endpoint.api.test.ts index 58105b0387..666c5df63a 100644 --- a/src/common/k8s-api/__tests__/endpoint.api.test.ts +++ b/src/common/k8s-api/__tests__/endpoint.api.test.ts @@ -3,42 +3,26 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { EndpointSubset } from "../endpoints"; +import { formatEndpointSubset } from "../endpoints"; describe("endpoint tests", () => { describe("EndpointSubset", () => { - it.each([ - 4, - false, - null, - {}, - [], - "ahe", - /a/, - ])("should always initialize fields when given %j", (data: any) => { - const sub = new EndpointSubset(data); - - expect(sub.addresses).toStrictEqual([]); - expect(sub.notReadyAddresses).toStrictEqual([]); - expect(sub.ports).toStrictEqual([]); - }); - - it("toString should be addresses X ports", () => { - const sub = new EndpointSubset({ + it("formatEndpointSubset should be addresses X ports", () => { + const formatted = formatEndpointSubset({ addresses: [{ ip: "1.1.1.1", }, { ip: "1.1.1.2", - }] as any, + }], notReadyAddresses: [], ports: [{ - port: "81", + port: 81, }, { - port: "82", - }] as any, + port: 82, + }], }); - expect(sub.toString()).toBe("1.1.1.1:81, 1.1.1.1:82, 1.1.1.2:81, 1.1.1.2:82"); + expect(formatted).toBe("1.1.1.1:81, 1.1.1.1:82, 1.1.1.2:81, 1.1.1.2:82"); }); }); }); diff --git a/src/common/k8s-api/__tests__/helm-charts.api.test.ts b/src/common/k8s-api/__tests__/helm-charts.api.test.ts index 283c5168f5..e7a4cb9439 100644 --- a/src/common/k8s-api/__tests__/helm-charts.api.test.ts +++ b/src/common/k8s-api/__tests__/helm-charts.api.test.ts @@ -9,33 +9,33 @@ import { HelmChart } from "../endpoints/helm-charts.api"; describe("HelmChart tests", () => { describe("HelmChart.create() tests", () => { it("should throw on non-object input", () => { - expect(() => HelmChart.create("" as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create(1 as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create(false as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create([] as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create(Symbol() as any)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create("" as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(1 as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(false as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create([] as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(Symbol() as never)).toThrowError('"value" must be of type object'); }); it("should throw on missing fields", () => { - expect(() => HelmChart.create({} as any)).toThrowError('"apiVersion" is required'); + expect(() => HelmChart.create({} as never)).toThrowError('"apiVersion" is required'); expect(() => HelmChart.create({ apiVersion: "!", - } as any)).toThrowError('"name" is required'); + } as never)).toThrowError('"name" is required'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", - } as any)).toThrowError('"version" is required'); + } as never)).toThrowError('"version" is required'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", version: "!", - } as any)).toThrowError('"repo" is required'); + } as never)).toThrowError('"repo" is required'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", version: "!", repo: "!", - } as any)).toThrowError('"created" is required'); + } as never)).toThrowError('"created" is required'); }); it("should throw on fields being wrong type", () => { @@ -46,7 +46,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: "!", - } as any)).toThrowError('"apiVersion" must be a string'); + } as never)).toThrowError('"apiVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: 1, @@ -54,7 +54,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: "!", - } as any)).toThrowError('"name" must be a string'); + } as never)).toThrowError('"name" must be a string'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", @@ -62,7 +62,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: 1, - } as any)).toThrowError('"digest" must be a string'); + } as never)).toThrowError('"digest" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "", @@ -70,7 +70,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: "!", - } as any)).toThrowError('"version" must be a string'); + } as never)).toThrowError('"version" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -78,7 +78,7 @@ describe("HelmChart tests", () => { repo: 1, created: "!", digest: "!", - } as any)).toThrowError('"repo" must be a string'); + } as never)).toThrowError('"repo" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -86,7 +86,7 @@ describe("HelmChart tests", () => { repo: "1", created: 1, digest: "a", - } as any)).toThrowError('"created" must be a string'); + } as never)).toThrowError('"created" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -94,7 +94,7 @@ describe("HelmChart tests", () => { repo: "1", created: "!", digest: 1, - } as any)).toThrowError('"digest" must be a string'); + } as never)).toThrowError('"digest" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -103,7 +103,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", kubeVersion: 1, - } as any)).toThrowError('"kubeVersion" must be a string'); + } as never)).toThrowError('"kubeVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -112,7 +112,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", description: 1, - } as any)).toThrowError('"description" must be a string'); + } as never)).toThrowError('"description" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -121,7 +121,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", home: 1, - } as any)).toThrowError('"home" must be a string'); + } as never)).toThrowError('"home" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -130,7 +130,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", engine: 1, - } as any)).toThrowError('"engine" must be a string'); + } as never)).toThrowError('"engine" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -139,7 +139,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", icon: 1, - } as any)).toThrowError('"icon" must be a string'); + } as never)).toThrowError('"icon" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -148,7 +148,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", appVersion: 1, - } as any)).toThrowError('"appVersion" must be a string'); + } as never)).toThrowError('"appVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -157,7 +157,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", tillerVersion: 1, - } as any)).toThrowError('"tillerVersion" must be a string'); + } as never)).toThrowError('"tillerVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -166,7 +166,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", deprecated: 1, - } as any)).toThrowError('"deprecated" must be a boolean'); + } as never)).toThrowError('"deprecated" must be a boolean'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -175,7 +175,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", keywords: 1, - } as any)).toThrowError('"keywords" must be an array'); + } as never)).toThrowError('"keywords" must be an array'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -184,7 +184,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", sources: 1, - } as any)).toThrowError('"sources" must be an array'); + } as never)).toThrowError('"sources" must be an array'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -193,7 +193,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", maintainers: 1, - } as any)).toThrowError('"maintainers" must be an array'); + } as never)).toThrowError('"maintainers" must be an array'); }); it("should filter non-string keywords", () => { @@ -204,10 +204,10 @@ describe("HelmChart tests", () => { repo: "1", digest: "1", created: "!", - keywords: [1, "a", false, {}, "b"] as any, + keywords: [1, "a", false, {}, "b"] as never, }); - expect(chart.keywords).toStrictEqual(["a", "b"]); + expect(chart?.keywords).toStrictEqual(["a", "b"]); }); it("should filter non-string sources", () => { @@ -218,10 +218,10 @@ describe("HelmChart tests", () => { repo: "1", digest: "1", created: "!", - sources: [1, "a", false, {}, "b"] as any, + sources: [1, "a", false, {}, "b"] as never, }); - expect(chart.sources).toStrictEqual(["a", "b"]); + expect(chart?.sources).toStrictEqual(["a", "b"]); }); it("should filter invalid maintainers", () => { @@ -236,10 +236,10 @@ describe("HelmChart tests", () => { name: "a", email: "b", url: "c", - }] as any, + }] as never, }); - expect(chart.maintainers).toStrictEqual([{ + expect(chart?.maintainers).toStrictEqual([{ name: "a", email: "b", url: "c", @@ -261,9 +261,9 @@ describe("HelmChart tests", () => { name: "a", email: "b", url: "c", - }] as any, + }] as never, "asdjhajksdhadjks": 1, - } as any); + } as never); expect(warnFn).toHaveBeenCalledWith("HelmChart data has unexpected fields", { original: anyObject(), diff --git a/src/common/k8s-api/__tests__/ingress.api.ts b/src/common/k8s-api/__tests__/ingress.api.ts index 3b84c37038..d5e4d323ad 100644 --- a/src/common/k8s-api/__tests__/ingress.api.ts +++ b/src/common/k8s-api/__tests__/ingress.api.ts @@ -14,6 +14,8 @@ describe("computeRuleDeclarations", () => { name: "foo", resourceVersion: "1", uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", }, }); @@ -21,6 +23,7 @@ describe("computeRuleDeclarations", () => { host: "foo.bar", http: { paths: [{ + pathType: "Exact", backend: { service: { name: "my-service", @@ -44,6 +47,8 @@ describe("computeRuleDeclarations", () => { name: "foo", resourceVersion: "1", uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", }, }); @@ -55,6 +60,7 @@ describe("computeRuleDeclarations", () => { host: "foo.bar", http: { paths: [{ + pathType: "Exact", backend: { service: { name: "my-service", @@ -78,6 +84,8 @@ describe("computeRuleDeclarations", () => { name: "foo", resourceVersion: "1", uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", }, }); @@ -91,6 +99,7 @@ describe("computeRuleDeclarations", () => { host: "foo.bar", http: { paths: [{ + pathType: "Exact", backend: { service: { name: "my-service", diff --git a/src/common/k8s-api/__tests__/kube-api-parse.test.ts b/src/common/k8s-api/__tests__/kube-api-parse.test.ts index 8801837256..547f78adef 100644 --- a/src/common/k8s-api/__tests__/kube-api-parse.test.ts +++ b/src/common/k8s-api/__tests__/kube-api-parse.test.ts @@ -19,7 +19,7 @@ import { parseKubeApi } from "../kube-api-parse"; /** * [, ] */ -type KubeApiParseTestData = [string, Required]; +type KubeApiParseTestData = [string, IKubeApiParsed]; const tests: KubeApiParseTestData[] = [ ["/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", { @@ -126,6 +126,6 @@ describe("parseApi unit tests", () => { }); it.each(throwtests)("testing %j should throw", (url) => { - expect(() => parseKubeApi(url)).toThrowError("invalid apiPath"); + expect(() => parseKubeApi(url as never)).toThrowError("invalid apiPath"); }); }); diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index b5b4094bf7..9efa23bdaf 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -3,34 +3,34 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { Request } from "node-fetch"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; import AbortController from "abort-controller"; import { delay } from "../../utils/delay"; import { PassThrough } from "stream"; -import type { ApiManager } from "../api-manager"; -import { apiManager } from "../api-manager"; -import { Ingress, Pod } from "../endpoints"; +import { ApiManager } from "../api-manager"; +import type { FetchMock } from "jest-fetch-mock/types"; +import { DeploymentApi, Ingress, IngressApi, Pod, PodApi } from "../endpoints"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import apiManagerInjectable from "../api-manager/manager.injectable"; +import autoRegistrationInjectable from "../api-manager/auto-registration.injectable"; jest.mock("../api-manager"); -const mockApiManager = apiManager as jest.Mocked; - -class TestKubeObject extends KubeObject { - static kind = "Pod"; - static namespaced = true; - static apiBase = "/api/v1/pods"; -} - -class TestKubeApi extends KubeApi { - public async checkPreferredVersion() { - return super.checkPreferredVersion(); - } -} +const mockFetch = fetch as FetchMock; describe("forRemoteCluster", () => { + let apiManager: jest.Mocked; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + apiManager = new ApiManager() as jest.Mocked; + + di.override(apiManagerInjectable, () => apiManager); + }); + it("builds api client for KubeObject", async () => { const api = forRemoteCluster({ cluster: { @@ -39,7 +39,7 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, TestKubeObject); + }, Pod); expect(api).toBeInstanceOf(KubeApi); }); @@ -52,9 +52,9 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, TestKubeObject, TestKubeApi); + }, Pod, PodApi); - expect(api).toBeInstanceOf(TestKubeApi); + expect(api).toBeInstanceOf(PodApi); }); it("calls right api endpoint", async () => { @@ -65,9 +65,9 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, TestKubeObject); + }, Pod); - (fetch as any).mockResponse(async (request: any) => { + mockFetch.mockResponse(async (request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); return { @@ -83,22 +83,29 @@ describe("forRemoteCluster", () => { describe("KubeApi", () => { let request: KubeJsonApi; + let apiManager: jest.Mocked; beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + request = new KubeJsonApi({ serverAddress: `http://127.0.0.1:9999`, apiBase: "/api-kube", }); + apiManager = new ApiManager() as jest.Mocked; + + di.override(apiManagerInjectable, () => apiManager); + di.inject(autoRegistrationInjectable); }); it("uses url from apiBase if apiBase contains the resource", async () => { - (fetch as any).mockResponse(async (request: any) => { + mockFetch.mockResponse(async (request: any) => { if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ resources: [{ name: "ingresses", - }] as any[], + }], }), }; } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { @@ -107,13 +114,13 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses", - }] as any[], + }], }), }; } else { return { body: JSON.stringify({ - resources: [] as any[], + resources: [], }), }; } @@ -121,9 +128,9 @@ describe("KubeApi", () => { const apiBase = "/apis/networking.k8s.io/v1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; - const kubeApi = new KubeApi({ + const kubeApi = new IngressApi({ request, - objectConstructor: KubeObject, + objectConstructor: Ingress, apiBase, fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, @@ -138,11 +145,11 @@ describe("KubeApi", () => { }); it("uses url from fallbackApiBases if apiBase lacks the resource", async () => { - (fetch as any).mockResponse(async (request: any) => { + mockFetch.mockResponse(async (request: any) => { if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ - resources: [] as any[], + resources: [], }), }; } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { @@ -150,13 +157,13 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses", - }] as any[], + }], }), }; } else { return { body: JSON.stringify({ - resources: [] as any[], + resources: [], }), }; } @@ -164,9 +171,10 @@ describe("KubeApi", () => { const apiBase = "apis/networking.k8s.io/v1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; - const kubeApi = new KubeApi({ + const kubeApi = new IngressApi({ request, objectConstructor: Object.assign(KubeObject, { apiBase }), + kind: "Ingress", fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, }); @@ -183,107 +191,100 @@ describe("KubeApi", () => { it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred", async () => { expect.hasAssertions(); - const api = new TestKubeApi({ + const api = new IngressApi({ objectConstructor: Ingress, checkPreferredVersion: true, fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], request: { get: jest.fn() - .mockImplementationOnce((path: string) => { - expect(path).toBe("/apis/networking.k8s.io/v1"); - - throw new Error("no"); - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/apis/extensions/v1beta1"); - - return { - resources: [ - { - name: "ingresses", - }, - ], - }; - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/apis/extensions"); - - return { - preferredVersion: { - version: "v1beta1", - }, - }; + .mockImplementation((path: string) => { + switch (path) { + case "/apis/networking.k8s.io/v1": + throw new Error("no"); + case "/apis/extensions/v1beta1": + return { + resources: [ + { + name: "ingresses", + }, + ], + }; + case "/apis/extensions": + return { + preferredVersion: { + version: "v1beta1", + }, + }; + default: + throw new Error("unknown path"); + } }), - } as any, + } as Partial as KubeJsonApi, }); - await api.checkPreferredVersion(); + await (api as any).checkPreferredVersion(); expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(mockApiManager.registerApi).toBeCalledWith("/apis/extensions/v1beta1/ingresses", expect.anything()); + expect(apiManager.registerApi).toBeCalledWith(api); }); it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred with non-grouped apis", async () => { expect.hasAssertions(); - const api = new TestKubeApi({ + const api = new PodApi({ objectConstructor: Pod, checkPreferredVersion: true, fallbackApiBases: ["/api/v1beta1/pods"], request: { get: jest.fn() - .mockImplementationOnce((path: string) => { - expect(path).toBe("/api/v1"); - - throw new Error("no"); - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/api/v1beta1"); - - return { - resources: [ - { - name: "pods", - }, - ], - }; - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/api"); - - return { - preferredVersion: { - version: "v1beta1", - }, - }; + .mockImplementation((path: string) => { + switch (path) { + case "/api/v1": + throw new Error("no"); + case "/api/v1beta1": + return { + resources: [ + { + name: "pods", + }, + ], + }; + case "/api": + return { + preferredVersion: { + version: "v1beta1", + }, + }; + default: + throw new Error("unknown path"); + } }), - } as any, + } as Partial as KubeJsonApi, }); - await api.checkPreferredVersion(); + await (api as any).checkPreferredVersion(); expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(mockApiManager.registerApi).toBeCalledWith("/api/v1beta1/pods", expect.anything()); + expect(apiManager.registerApi).toBeCalledWith(api); }); }); describe("patch", () => { - let api: TestKubeApi; + let api: DeploymentApi; beforeEach(() => { - api = new TestKubeApi({ + api = new DeploymentApi({ request, - objectConstructor: TestKubeObject, }); }); it("sends strategic patch by default", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); - expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); return {}; }); @@ -296,10 +297,10 @@ describe("KubeApi", () => { it("allows to use merge patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); return {}; }); @@ -312,10 +313,10 @@ describe("KubeApi", () => { it("allows to use json patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("json-patch"); - expect(request.body.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); + expect(request.body?.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); return {}; }); @@ -328,10 +329,10 @@ describe("KubeApi", () => { it("allows deep partial patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); + expect(request.body?.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); return {}; }); @@ -345,18 +346,18 @@ describe("KubeApi", () => { }); describe("delete", () => { - let api: TestKubeApi; + let api: PodApi; beforeEach(() => { - api = new TestKubeApi({ + api = new PodApi({ request, - objectConstructor: TestKubeObject, + objectConstructor: Pod, }); }); it("sends correct request with empty namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); @@ -368,7 +369,7 @@ describe("KubeApi", () => { it("sends correct request without namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); @@ -380,7 +381,7 @@ describe("KubeApi", () => { it("sends correct request with namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); @@ -392,7 +393,7 @@ describe("KubeApi", () => { it("allows to change propagationPolicy", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toMatch("propagationPolicy=Orphan"); @@ -404,13 +405,13 @@ describe("KubeApi", () => { }); describe("watch", () => { - let api: TestKubeApi; + let api: PodApi; let stream: PassThrough; beforeEach(() => { - api = new TestKubeApi({ + api = new PodApi({ request, - objectConstructor: TestKubeObject, + objectConstructor: Pod, }); stream = new PassThrough(); }); @@ -423,9 +424,10 @@ describe("KubeApi", () => { it("sends a valid watch request", () => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + mockFetch.mockResponse(async () => { return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -436,9 +438,10 @@ describe("KubeApi", () => { it("sends timeout as a query parameter", async () => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + mockFetch.mockResponse(async () => { return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -449,13 +452,14 @@ describe("KubeApi", () => { it("aborts watch using abortController", async (done) => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async (request: Request) => { - (request as any).signal.addEventListener("abort", () => { + mockFetch.mockResponse(async request => { + request.signal.addEventListener("abort", () => { done(); }); return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -478,9 +482,9 @@ describe("KubeApi", () => { it("if request ended", (done) => { const spy = jest.spyOn(request, "getResponse"); - jest.spyOn(stream, "on").mockImplementation((eventName: string, callback: Function) => { + jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { // End the request in 100ms. - if (eventName === "end") { + if (event === "end") { setTimeout(() => { callback(); }, 100); @@ -493,8 +497,8 @@ describe("KubeApi", () => { jest.spyOn(global, "fetch").mockImplementation(async () => { return { ok: true, - body: stream, - } as any; + body: stream as never, + } as Partial as Response; }); api.watch({ @@ -512,9 +516,10 @@ describe("KubeApi", () => { it("if request not closed after timeout", (done) => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + mockFetch.mockResponse(async () => { return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -536,9 +541,9 @@ describe("KubeApi", () => { it("retries only once if request ends and timeout is set", (done) => { const spy = jest.spyOn(request, "getResponse"); - jest.spyOn(stream, "on").mockImplementation((eventName: string, callback: Function) => { + jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { // End the request in 100ms. - if (eventName === "end") { + if (event === "end") { setTimeout(() => { callback(); }, 100); @@ -551,8 +556,8 @@ describe("KubeApi", () => { jest.spyOn(global, "fetch").mockImplementation(async () => { return { ok: true, - body: stream, - } as any; + body: stream as never, + } as Partial as Response; }); const timeoutSeconds = 0.5; @@ -577,21 +582,21 @@ describe("KubeApi", () => { }); describe("create", () => { - let api: TestKubeApi; + let api: PodApi; beforeEach(() => { - api = new TestKubeApi({ + api = new PodApi({ request, - objectConstructor: TestKubeObject, + objectConstructor: Pod, }); }); it("should add kind and apiVersion", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("POST"); - expect(JSON.parse(request.body.toString())).toEqual({ + expect(JSON.parse(String(request.body))).toEqual({ kind: "Pod", apiVersion: "v1", metadata: { @@ -643,9 +648,9 @@ describe("KubeApi", () => { it("doesn't override metadata.labels", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("POST"); - expect(JSON.parse(request.body.toString())).toEqual({ + expect(JSON.parse(String(request.body))).toEqual({ kind: "Pod", apiVersion: "v1", metadata: { @@ -674,21 +679,21 @@ describe("KubeApi", () => { }); describe("update", () => { - let api: TestKubeApi; + let api: PodApi; beforeEach(() => { - api = new TestKubeApi({ + api = new PodApi({ request, - objectConstructor: TestKubeObject, + objectConstructor: Pod, }); }); it("doesn't override metadata.labels", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PUT"); - expect(JSON.parse(request.body.toString())).toEqual({ + expect(JSON.parse(String(request.body))).toEqual({ metadata: { name: "foobar", namespace: "default", diff --git a/src/common/k8s-api/__tests__/kube-object.store.test.ts b/src/common/k8s-api/__tests__/kube-object.store.test.ts index f0f26ec0d8..91ed80fbde 100644 --- a/src/common/k8s-api/__tests__/kube-object.store.test.ts +++ b/src/common/k8s-api/__tests__/kube-object.store.test.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { Cluster } from "../../cluster/cluster"; import type { ClusterContext } from "../cluster-context"; import type { KubeApi } from "../kube-api"; import { KubeObject } from "../kube-object"; @@ -14,6 +15,7 @@ class FakeKubeObjectStore extends KubeObjectStore { allNamespaces: [], contextNamespaces: [], hasSelectedAll: false, + cluster: {} as Cluster, } as ClusterContext; get context() { @@ -40,6 +42,7 @@ describe("KubeObjectStore", () => { resourceVersion: "1", uid: "some-uid", namespace: "default", + selfLink: "/some/self/link", }, }); const store = new FakeKubeObjectStore(loadItems, { @@ -73,6 +76,7 @@ describe("KubeObjectStore", () => { resourceVersion: "1", uid: "some-uid", namespace: "default", + selfLink: "/some/self/link", }, }); const objNotInDefaultNamespace = new KubeObject({ @@ -83,6 +87,7 @@ describe("KubeObjectStore", () => { resourceVersion: "1", uid: "some-uid", namespace: "not-default", + selfLink: "/some/self/link", }, }); const store = new FakeKubeObjectStore(loadItems, { @@ -115,6 +120,7 @@ describe("KubeObjectStore", () => { name: "some-obj-name", resourceVersion: "1", uid: "some-uid", + selfLink: "/some/self/link", }, }); const clusterScopedObject2 = new KubeObject({ @@ -125,6 +131,7 @@ describe("KubeObjectStore", () => { resourceVersion: "1", uid: "some-uid", namespace: "not-default", + selfLink: "/some/self/link", }, }); const store = new FakeKubeObjectStore(loadItems, { diff --git a/src/common/k8s-api/__tests__/nodes.test.ts b/src/common/k8s-api/__tests__/node.test.ts similarity index 66% rename from src/common/k8s-api/__tests__/nodes.test.ts rename to src/common/k8s-api/__tests__/node.test.ts index 4b109a8f96..53ffc59d79 100644 --- a/src/common/k8s-api/__tests__/nodes.test.ts +++ b/src/common/k8s-api/__tests__/node.test.ts @@ -8,7 +8,61 @@ import { Node } from "../endpoints"; * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -describe("Nodes tests", () => { +describe("Node tests", () => { + describe("isMasterNode()", () => { + it("given a master node labelled before kubernetes 1.20, should return true", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/master": "NoSchedule", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(true); + }); + + it("given a master node labelled after kubernetes 1.20, should return true", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/control-plane": "NoSchedule", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(true); + }); + + it("given a non master node, should return false", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: {}, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(false); + }); + }); + describe("getRoleLabels()", () => { it("should return empty string if labels is not present", () => { const node = new Node({ @@ -18,6 +72,7 @@ describe("Nodes tests", () => { name: "bar", resourceVersion: "1", uid: "bat", + selfLink: "/api/v1/nodes/bar", }, }); @@ -33,6 +88,7 @@ describe("Nodes tests", () => { resourceVersion: "1", uid: "bat", labels: {}, + selfLink: "/api/v1/nodes/bar", }, }); @@ -51,6 +107,7 @@ describe("Nodes tests", () => { "node-role.kubernetes.io/foobar": "bat", "hellonode-role.kubernetes.io/foobar1": "bat", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -69,6 +126,7 @@ describe("Nodes tests", () => { "node-role.kubernetes.io/foobar": "bat", "hellonode-role.kubernetes.io//////foobar1": "bat", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -86,6 +144,7 @@ describe("Nodes tests", () => { labels: { "kubernetes.io/role": "master", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -103,6 +162,7 @@ describe("Nodes tests", () => { labels: { "node.kubernetes.io/role": "master", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -122,6 +182,7 @@ describe("Nodes tests", () => { "kubernetes.io/role": "master", "node.kubernetes.io/role": "master-v2-max", }, + selfLink: "/api/v1/nodes/bar", }, }); diff --git a/src/common/k8s-api/__tests__/pods.api.test.ts b/src/common/k8s-api/__tests__/pods.api.test.ts index 16b57b1b48..9b01dff73b 100644 --- a/src/common/k8s-api/__tests__/pods.api.test.ts +++ b/src/common/k8s-api/__tests__/pods.api.test.ts @@ -14,6 +14,8 @@ describe("Pod tests", () => { name: "foobar", resourceVersion: "foobar", uid: "foobar", + namespace: "default", + selfLink: "/api/v1/pods/default/foobar", }, }); diff --git a/src/common/k8s-api/__tests__/pods.test.ts b/src/common/k8s-api/__tests__/pods.test.ts index a7faee1e45..826c2b4617 100644 --- a/src/common/k8s-api/__tests__/pods.test.ts +++ b/src/common/k8s-api/__tests__/pods.test.ts @@ -3,6 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import assert from "assert"; +import type { PodContainer, PodContainerStatus } from "../endpoints"; import { Pod } from "../endpoints"; interface GetDummyPodOptions { @@ -12,16 +14,18 @@ interface GetDummyPodOptions { initDead?: number; } -function getDummyPodDefaultOptions(): Required { - return { - running: 0, - dead: 0, - initDead: 0, - initRunning: 0, - }; -} +function getDummyPod(rawOpts: GetDummyPodOptions = {}): Pod { + const { + running = 0, + dead = 0, + initDead = 0, + initRunning = 0, + } = rawOpts; -function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Pod { + const containers: PodContainer[] = []; + const initContainers: PodContainer[] = []; + const containerStatuses: PodContainerStatus[] = []; + const initContainerStatuses: PodContainerStatus[] = []; const pod = new Pod({ apiVersion: "v1", kind: "Pod", @@ -29,36 +33,35 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po uid: "1", name: "test", resourceVersion: "v1", - selfLink: "http", + namespace: "default", + selfLink: "/api/v1/pods/default/test", + }, + spec: { + containers, + initContainers, + serviceAccount: "dummy", + serviceAccountName: "dummy", + }, + status: { + phase: "Running", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses, + initContainerStatuses, }, }); - pod.spec = { - containers: [], - initContainers: [], - serviceAccount: "dummy", - serviceAccountName: "dummy", - }; + for (let i = 0; i < running; i += 1) { + const name = `container_running_${i}`; - pod.status = { - phase: "Running", - conditions: [], - hostIP: "10.0.0.1", - podIP: "10.0.0.1", - startTime: "now", - containerStatuses: [], - initContainerStatuses: [], - }; - - for (let i = 0; i < opts.running; i += 1) { - const name = `container_r_${i}`; - - pod.spec.containers.push({ + containers.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.containerStatuses.push({ + containerStatuses.push({ image: "dummy", imageID: "dummy", name, @@ -72,15 +75,15 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po }); } - for (let i = 0; i < opts.dead; i += 1) { - const name = `container_d_${i}`; + for (let i = 0; i < dead; i += 1) { + const name = `container_dead_${i}`; - pod.spec.containers.push({ + containers.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.containerStatuses.push({ + containerStatuses.push({ image: "dummy", imageID: "dummy", name, @@ -97,15 +100,15 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po }); } - for (let i = 0; i < opts.initRunning; i += 1) { - const name = `container_ir_${i}`; + for (let i = 0; i < initRunning; i += 1) { + const name = `container_init-running_${i}`; - pod.spec.initContainers.push({ + initContainers.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.initContainerStatuses.push({ + initContainerStatuses.push({ image: "dummy", imageID: "dummy", name, @@ -119,15 +122,15 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po }); } - for (let i = 0; i < opts.initDead; i += 1) { - const name = `container_id_${i}`; + for (let i = 0; i < initDead; i += 1) { + const name = `container_init-dead_${i}`; - pod.spec.initContainers.push({ + initContainers.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.initContainerStatuses.push({ + initContainerStatuses.push({ image: "dummy", imageID: "dummy", name, @@ -173,8 +176,8 @@ describe("Pods", () => { it("getRunningContainers should return only running and init running", () => { const res = [ - ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_r_${index}`)), - ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_ir_${index}`)), + ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_running_${index}`)), + ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_init-running_${index}`)), ]; expect(pod.getRunningContainers()).toStrictEqual(res); @@ -182,10 +185,10 @@ describe("Pods", () => { it("getAllContainers should return all containers", () => { const res = [ - ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_r_${index}`)), - ...Array.from(new Array(dead), (val, index) => getNamedContainer(`container_d_${index}`)), - ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_ir_${index}`)), - ...Array.from(new Array(initDead), (val, index) => getNamedContainer(`container_id_${index}`)), + ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_running_${index}`)), + ...Array.from(new Array(dead), (val, index) => getNamedContainer(`container_dead_${index}`)), + ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_init-running_${index}`)), + ...Array.from(new Array(initDead), (val, index) => getNamedContainer(`container_init-dead_${index}`)), ]; expect(pod.getAllContainers()).toStrictEqual(res); @@ -253,7 +256,7 @@ describe("Pods", () => { it("should return true if a condition isn't ready", () => { const pod = getDummyPod({ running: 1 }); - pod.status.conditions.push({ + pod.status?.conditions.push({ type: "Ready", status: "foobar", lastProbeTime: 1, @@ -266,7 +269,7 @@ describe("Pods", () => { it("should return false if a condition is non-ready", () => { const pod = getDummyPod({ running: 1 }); - pod.status.conditions.push({ + pod.status?.conditions.push({ type: "dummy", status: "foobar", lastProbeTime: 1, @@ -278,8 +281,11 @@ describe("Pods", () => { it("should return true if a current container is in a crash loop back off", () => { const pod = getDummyPod({ running: 1 }); + const firstStatus = pod.status?.containerStatuses?.[0]; - pod.status.containerStatuses[0].state = { + assert(firstStatus); + + firstStatus.state = { waiting: { reason: "CrashLookBackOff", message: "too much foobar", @@ -292,6 +298,8 @@ describe("Pods", () => { it("should return true if a current phase isn't running", () => { const pod = getDummyPod({ running: 1 }); + assert(pod.status); + pod.status.phase = "not running"; expect(pod.hasIssues()).toStrictEqual(true); @@ -300,6 +308,8 @@ describe("Pods", () => { it("should return false if a current phase is running", () => { const pod = getDummyPod({ running: 1 }); + assert(pod.status); + pod.status.phase = "Running"; expect(pod.hasIssues()).toStrictEqual(false); diff --git a/src/common/k8s-api/__tests__/stateful-set.api.test.ts b/src/common/k8s-api/__tests__/stateful-set.api.test.ts index 624a852c9d..b7557c8010 100644 --- a/src/common/k8s-api/__tests__/stateful-set.api.test.ts +++ b/src/common/k8s-api/__tests__/stateful-set.api.test.ts @@ -3,31 +3,39 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { StatefulSet, StatefulSetApi } from "../endpoints/stateful-set.api"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; +import type { StatefulSetApi } from "../endpoints"; +import statefulSetApiInjectable from "../endpoints/stateful-set.api.injectable"; import type { KubeJsonApi } from "../kube-json-api"; -class StatefulSetApiTest extends StatefulSetApi { - public setRequest(request: any) { - this.request = request; - } -} - describe("StatefulSetApi", () => { + let statefulSetApi: StatefulSetApi; + let kubeJsonApi: jest.Mocked; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(storesAndApisCanBeCreatedInjectable, () => true); + kubeJsonApi = { + getResponse: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + del: jest.fn(), + } as never; + di.override(apiKubeInjectable, () => kubeJsonApi); + + statefulSetApi = di.inject(statefulSetApiInjectable); + }); + describe("scale", () => { - const requestMock = { - patch: () => ({}), - } as unknown as KubeJsonApi; - - const sub = new StatefulSetApiTest({ objectConstructor: StatefulSet }); - - sub.setRequest(requestMock); - it("requests Kubernetes API with PATCH verb and correct amount of replicas", () => { - const patchSpy = jest.spyOn(requestMock, "patch"); + statefulSetApi.scale({ namespace: "default", name: "statefulset-1" }, 5); - sub.scale({ namespace: "default", name: "statefulset-1" }, 5); - - expect(patchSpy).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/statefulsets/statefulset-1/scale", { + expect(kubeJsonApi.patch).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/statefulsets/statefulset-1/scale", { data: { spec: { replicas: 5, diff --git a/src/common/k8s-api/api-kube.ts b/src/common/k8s-api/api-kube.ts index 5ff8478694..4154ec2f00 100644 --- a/src/common/k8s-api/api-kube.ts +++ b/src/common/k8s-api/api-kube.ts @@ -3,18 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isClusterPageContext } from "../utils"; -import { KubeJsonApi } from "./kube-json-api"; -import { apiKubePrefix, isDevelopment } from "../vars"; +import { getInjectionToken } from "@ogre-tools/injectable"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import type { KubeJsonApi } from "./kube-json-api"; -export const apiKube = isClusterPageContext() - ? new KubeJsonApi({ - serverAddress: `http://127.0.0.1:${window.location.port}`, - apiBase: apiKubePrefix, - debug: isDevelopment, - }, { - headers: { - "Host": window.location.host, - }, - }) - : undefined; +export const apiKubeInjectionToken = getInjectionToken({ + id: "api-kube-injection-token", +}); + +export const apiKube = asLegacyGlobalForExtensionApi(apiKubeInjectionToken); diff --git a/src/common/k8s-api/api-manager.ts b/src/common/k8s-api/api-manager.ts deleted file mode 100644 index b2d5021337..0000000000 --- a/src/common/k8s-api/api-manager.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { KubeObjectStore } from "./kube-object.store"; - -import { action, observable, makeObservable } from "mobx"; -import { autoBind, iter } from "../utils"; -import type { KubeApi } from "./kube-api"; -import type { KubeObject } from "./kube-object"; -import type { IKubeObjectRef } from "./kube-api-parse"; -import { parseKubeApi, createKubeApiURL } from "./kube-api-parse"; - -export class ApiManager { - private apis = observable.map>(); - private stores = observable.map>(); - - constructor() { - makeObservable(this); - autoBind(this); - } - - getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { - if (typeof pathOrCallback === "string") { - return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); - } - - return iter.find(this.apis.values(), pathOrCallback ?? (() => true)); - } - - getApiByKind(kind: string, apiVersion: string) { - return iter.find(this.apis.values(), api => api.kind === kind && api.apiVersionWithGroup === apiVersion); - } - - registerApi(apiBase: string, api: KubeApi) { - if (!api.apiBase) return; - - if (!this.apis.has(apiBase)) { - this.stores.forEach((store) => { - if (store.api === api) { - this.stores.set(apiBase, store); - } - }); - - this.apis.set(apiBase, api); - } - } - - protected resolveApi(api?: string | KubeApi): KubeApi | undefined { - if (!api) { - return undefined; - } - - if (typeof api === "string") { - return this.getApi(api) as KubeApi; - } - - return api; - } - - unregisterApi(api: string | KubeApi) { - if (typeof api === "string") this.apis.delete(api); - else { - const apis = Array.from(this.apis.entries()); - const entry = apis.find(entry => entry[1] === api); - - if (entry) this.unregisterApi(entry[0]); - } - } - - @action - registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { - apis.filter(Boolean).forEach(api => { - if (api.apiBase) this.stores.set(api.apiBase, store); - }); - } - - getStore>(api: string | KubeApi): S | undefined { - return this.stores.get(this.resolveApi(api)?.apiBase) as S; - } - - lookupApiLink(ref: IKubeObjectRef, parentObject?: KubeObject): string { - const { - kind, apiVersion, name, - namespace = parentObject?.getNs(), - } = ref; - - if (!kind) return ""; - - // search in registered apis by 'kind' & 'apiVersion' - const api = this.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion); - - if (api) { - return api.getUrl({ namespace, name }); - } - - // lookup api by generated resource link - const apiPrefixes = ["/apis", "/api"]; - const resource = kind.endsWith("s") ? `${kind.toLowerCase()}es` : `${kind.toLowerCase()}s`; - - for (const apiPrefix of apiPrefixes) { - const apiLink = createKubeApiURL({ apiPrefix, apiVersion, name, namespace, resource }); - - if (this.getApi(apiLink)) { - return apiLink; - } - } - - // resolve by kind only (hpa's might use refs to older versions of resources for example) - const apiByKind = this.getApi(api => api.kind === kind); - - if (apiByKind) { - return apiByKind.getUrl({ name, namespace }); - } - - // otherwise generate link with default prefix - // resource still might exists in k8s, but api is not registered in the app - return createKubeApiURL({ apiVersion, name, namespace, resource }); - } -} - -export const apiManager = new ApiManager(); diff --git a/src/common/k8s-api/api-manager/api-manager.ts b/src/common/k8s-api/api-manager/api-manager.ts new file mode 100644 index 0000000000..080ccb671a --- /dev/null +++ b/src/common/k8s-api/api-manager/api-manager.ts @@ -0,0 +1,161 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectStore } from "../kube-object.store"; + +import { action, observable, makeObservable } from "mobx"; +import { autoBind, isDefined, iter } from "../../utils"; +import type { KubeApi } from "../kube-api"; +import type { KubeJsonApiDataFor, KubeObject, ObjectReference } from "../kube-object"; +import { parseKubeApi, createKubeApiURL } from "../kube-api-parse"; + +export type RegisterableStore = Store extends KubeObjectStore + ? Store + : never; +export type RegisterableApi = Api extends KubeApi + ? Api + : never; +export type KubeObjectStoreFrom = Api extends KubeApi + ? KubeObjectStore + : never; + +export class ApiManager { + private readonly apis = observable.map(); + private readonly stores = observable.map(); + + constructor() { + makeObservable(this); + autoBind(this); + } + + getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { + if (typeof pathOrCallback === "string") { + return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); + } + + return iter.find(this.apis.values(), pathOrCallback ?? (() => true)); + } + + getApiByKind(kind: string, apiVersion: string) { + return iter.find(this.apis.values(), api => api.kind === kind && api.apiVersionWithGroup === apiVersion); + } + + registerApi(api: RegisterableApi): void; + /** + * @deprecated Just register the `api` by itself + */ + registerApi(apiBase: string, api: RegisterableApi): void; + registerApi(apiBaseRaw: string | RegisterableApi, apiRaw?: RegisterableApi) { + const api = typeof apiBaseRaw === "string" + ? apiRaw + : apiBaseRaw; + + if (!api?.apiBase) { + return; + } + + if (!this.apis.has(api.apiBase)) { + this.stores.forEach((store) => { + if (store.api === api) { + this.stores.set(api.apiBase, store); + } + }); + + this.apis.set(api.apiBase, api); + } + } + + protected resolveApi(api: undefined | string | KubeApi): KubeApi | undefined { + if (!api) { + return undefined; + } + + if (typeof api === "string") { + return this.getApi(api); + } + + return api; + } + + unregisterApi(api: string | KubeApi) { + if (typeof api === "string") this.apis.delete(api); + else { + const apis = Array.from(this.apis.entries()); + const entry = apis.find(entry => entry[1] === api); + + if (entry) this.unregisterApi(entry[0]); + } + } + + registerStore(store: RegisterableStore): void; + /** + * @deprecated KubeObjectStore's should only every be about a single KubeApi type + */ + registerStore(store: KubeObjectStore, KubeJsonApiDataFor>, apis: KubeApi[]): void; + + @action + registerStore(store: KubeObjectStore, KubeJsonApiDataFor>, apis: KubeApi[] = [store.api]): void { + for (const api of apis.filter(isDefined)) { + if (api.apiBase) { + this.stores.set(api.apiBase, store as never); + } + } + } + + getStore(api: string | undefined): KubeObjectStore | undefined; + getStore(api: RegisterableApi): KubeObjectStoreFrom | undefined; + /** + * @deprecated use an actual cast instead of hiding it with this unused type param + */ + getStore(api: string | KubeApi): Store | undefined ; + getStore(api: string | KubeApi | undefined): KubeObjectStore | undefined { + const { apiBase } = this.resolveApi(api) ?? {}; + + if (apiBase) { + return this.stores.get(apiBase); + } + + return undefined; + } + + lookupApiLink(ref: ObjectReference, parentObject?: KubeObject): string { + const { + kind, apiVersion = "v1", name, + namespace = parentObject?.getNs(), + } = ref; + + if (!kind) return ""; + + // search in registered apis by 'kind' & 'apiVersion' + const api = this.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion); + + if (api) { + return api.getUrl({ namespace, name }); + } + + // lookup api by generated resource link + const apiPrefixes = ["/apis", "/api"]; + const resource = kind.endsWith("s") ? `${kind.toLowerCase()}es` : `${kind.toLowerCase()}s`; + + for (const apiPrefix of apiPrefixes) { + const apiLink = createKubeApiURL({ apiPrefix, apiVersion, name, namespace, resource }); + + if (this.getApi(apiLink)) { + return apiLink; + } + } + + // resolve by kind only (hpa's might use refs to older versions of resources for example) + const apiByKind = this.getApi(api => api.kind === kind); + + if (apiByKind) { + return apiByKind.getUrl({ name, namespace }); + } + + // otherwise generate link with default prefix + // resource still might exists in k8s, but api is not registered in the app + return createKubeApiURL({ apiVersion, name, namespace, resource }); + } +} diff --git a/src/common/k8s-api/api-manager/auto-registration-emitter.injectable.ts b/src/common/k8s-api/api-manager/auto-registration-emitter.injectable.ts new file mode 100644 index 0000000000..d9a68a988c --- /dev/null +++ b/src/common/k8s-api/api-manager/auto-registration-emitter.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import EventEmitter from "events"; +import type TypedEventEmitter from "typed-emitter"; +import type { CustomResourceDefinition } from "../endpoints"; +import type { KubeApi } from "../kube-api"; + +export interface LegacyAutoRegistration { + customResourceDefinition: (crd: CustomResourceDefinition) => void; + kubeApi: (api: KubeApi) => void; +} + +/** + * This is used to remove dependency cycles from auto registering of instances + * + * - Custom Resource Definitions get their own registered store (will need in the future) + * - All KubeApi's get auto registered (this should be changed in the future) + */ +const autoRegistrationEmitterInjectable = getInjectable({ + id: "auto-registration-emitter", + instantiate: (): TypedEventEmitter => new EventEmitter(), +}); + +export default autoRegistrationEmitterInjectable; diff --git a/src/common/k8s-api/api-manager/auto-registration.injectable.ts b/src/common/k8s-api/api-manager/auto-registration.injectable.ts new file mode 100644 index 0000000000..0cf1a3055d --- /dev/null +++ b/src/common/k8s-api/api-manager/auto-registration.injectable.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CustomResourceDefinition } from "../endpoints"; +import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; +import autoRegistrationEmitterInjectable from "./auto-registration-emitter.injectable"; +import apiManagerInjectable from "./manager.injectable"; +import { CustomResourceStore } from "./resource.store"; + +const autoRegistrationInjectable = getInjectable({ + id: "api-manager-auto-registration", + instantiate: (di) => { + const autoRegistrationEmitter = di.inject(autoRegistrationEmitterInjectable); + const beforeApiManagerInitializationCrds: CustomResourceDefinition[] = []; + const beforeApiManagerInitializationApis: KubeApi[] = []; + let initialized = false; + + const autoInitCustomResourceStore = (crd: CustomResourceDefinition) => { + const objectConstructor = class extends KubeObject { + static readonly kind = crd.getResourceKind(); + static readonly namespaced = crd.isNamespaced(); + static readonly apiBase = crd.getResourceApiBase(); + }; + + const api = (() => { + const rawApi = apiManager.getApi(objectConstructor.apiBase); + + if (rawApi) { + return rawApi; + } + + const api = new KubeApi({ objectConstructor }); + + apiManager.registerApi(api); + + return api; + })(); + + if (!apiManager.getStore(api)) { + apiManager.registerStore(new CustomResourceStore(api)); + } + }; + const autoInitKubeApi = (api: KubeApi) => { + apiManager.registerApi(api); + }; + + autoRegistrationEmitter + .on("customResourceDefinition", (crd) => { + if (initialized) { + autoInitCustomResourceStore(crd); + } else { + beforeApiManagerInitializationCrds.push(crd); + } + }) + .on("kubeApi", (api) => { + if (initialized) { + autoInitKubeApi(api); + } else { + beforeApiManagerInitializationApis.push(api); + } + }); + + const apiManager = di.inject(apiManagerInjectable); + + beforeApiManagerInitializationCrds.forEach(autoInitCustomResourceStore); + beforeApiManagerInitializationApis.forEach(autoInitKubeApi); + initialized = true; + }, +}); + +export default autoRegistrationInjectable; diff --git a/src/preload.ts b/src/common/k8s-api/api-manager/index.ts similarity index 73% rename from src/preload.ts rename to src/common/k8s-api/api-manager/index.ts index 64188789ea..56182e815b 100644 --- a/src/preload.ts +++ b/src/common/k8s-api/api-manager/index.ts @@ -3,8 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import fetch from "node-fetch"; - -export { - fetch, -}; +export * from "./api-manager"; diff --git a/src/common/k8s-api/api-manager/manager.injectable.ts b/src/common/k8s-api/api-manager/manager.injectable.ts new file mode 100644 index 0000000000..45188cec7a --- /dev/null +++ b/src/common/k8s-api/api-manager/manager.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import type { KubeObjectStore } from "../kube-object.store"; +import { ApiManager } from "./api-manager"; + +export const kubeObjectStoreInjectionToken = getInjectionToken>({ + id: "kube-object-store-token", +}); + +const apiManagerInjectable = getInjectable({ + id: "api-manager", + instantiate: (di) => { + const apiManager = new ApiManager(); + + if (di.inject(storesAndApisCanBeCreatedInjectionToken)) { + const stores = di.injectMany(kubeObjectStoreInjectionToken); + + for (const store of stores) { + apiManager.registerStore(store); + } + } + + return apiManager; + }, +}); + +export default apiManagerInjectable; diff --git a/src/common/k8s-api/api-manager/resource.store.ts b/src/common/k8s-api/api-manager/resource.store.ts new file mode 100644 index 0000000000..63ccdcf93d --- /dev/null +++ b/src/common/k8s-api/api-manager/resource.store.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeApi } from "../kube-api"; +import { KubeObjectStore } from "../kube-object.store"; +import type { KubeObject } from "../kube-object"; + +export class CustomResourceStore extends KubeObjectStore> { + constructor(api: KubeApi) { + super(api); + } +} diff --git a/src/common/k8s-api/cluster-context.ts b/src/common/k8s-api/cluster-context.ts index b85d8f74c5..098d92642d 100644 --- a/src/common/k8s-api/cluster-context.ts +++ b/src/common/k8s-api/cluster-context.ts @@ -6,7 +6,7 @@ import type { Cluster } from "../cluster/cluster"; export interface ClusterContext { - cluster?: Cluster; + cluster: Cluster; allNamespaces: string[]; // available / allowed namespaces from cluster.ts contextNamespaces: string[]; // selected by user (see: namespace-select.tsx) hasSelectedAll: boolean; diff --git a/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts b/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts new file mode 100644 index 0000000000..c06f3baaff --- /dev/null +++ b/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ClusterRoleBindingApi } from "./cluster-role-binding.api"; + +const clusterRoleBindingApiInjectable = getInjectable({ + id: "cluster-role-binding-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "clusterRoleBindingApi is only accessible in certain environments"); + + return new ClusterRoleBindingApi(); + }, +}); + +export default clusterRoleBindingApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster-role-binding.api.ts b/src/common/k8s-api/endpoints/cluster-role-binding.api.ts index b6e6a517ae..52449be8a3 100644 --- a/src/common/k8s-api/endpoints/cluster-role-binding.api.ts +++ b/src/common/k8s-api/endpoints/cluster-role-binding.api.ts @@ -2,35 +2,39 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { RoleRef } from "./types/role-ref"; +import type { Subject } from "./types/subject"; -export type ClusterRoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; - -export interface ClusterRoleBindingSubject { - kind: ClusterRoleBindingSubjectKind; - name: string; - apiGroup?: string; - namespace?: string; +export interface ClusterRoleBindingData extends KubeJsonApiData, void, void> { + subjects?: Subject[]; + roleRef: RoleRef; } -export interface ClusterRoleBinding { - subjects?: ClusterRoleBindingSubject[]; - roleRef: { - kind: string; - name: string; - apiGroup?: string; - }; -} - -export class ClusterRoleBinding extends KubeObject { +export class ClusterRoleBinding extends KubeObject { static kind = "ClusterRoleBinding"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"; + subjects?: Subject[]; + roleRef: RoleRef; + + constructor({ + subjects, + roleRef, + ...rest + }: ClusterRoleBindingData) { + super(rest); + this.subjects = subjects; + this.roleRef = roleRef; + } + getSubjects() { - return this.subjects || []; + return this.subjects ?? []; } getSubjectNames(): string { @@ -38,17 +42,11 @@ export class ClusterRoleBinding extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let clusterRoleBindingApi: KubeApi; - -if (isClusterPageContext()) { - clusterRoleBindingApi = new KubeApi({ - objectConstructor: ClusterRoleBinding, - }); +export class ClusterRoleBindingApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ClusterRoleBinding, + }); + } } - -export { - clusterRoleBindingApi, -}; diff --git a/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts b/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts new file mode 100644 index 0000000000..19a8f83542 --- /dev/null +++ b/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ClusterRoleApi } from "./cluster-role.api"; + +const clusterRoleApiInjectable = getInjectable({ + id: "cluster-role-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "clusterRoleApi is only available in certain environments"); + + return new ClusterRoleApi(); + }, +}); + +export default clusterRoleApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster-role.api.ts b/src/common/k8s-api/endpoints/cluster-role.api.ts index 70a5ef05e8..ff9503f231 100644 --- a/src/common/k8s-api/endpoints/cluster-role.api.ts +++ b/src/common/k8s-api/endpoints/cluster-role.api.ts @@ -3,40 +3,43 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { AggregationRule } from "./types/aggregation-rule"; +import type { PolicyRule } from "./types/policy-rule"; -export interface ClusterRole { - rules: { - verbs: string[]; - apiGroups: string[]; - resources: string[]; - resourceNames?: string[]; - }[]; +export interface ClusterRoleData extends KubeJsonApiData, void, void> { + rules?: PolicyRule[]; + aggregationRule?: AggregationRule; } -export class ClusterRole extends KubeObject { +export class ClusterRole extends KubeObject { static kind = "ClusterRole"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"; + rules?: PolicyRule[]; + aggregationRule?: AggregationRule; + + constructor({ rules, aggregationRule, ...rest }: ClusterRoleData) { + super(rest); + this.rules = rules; + this.aggregationRule = aggregationRule; + } + getRules() { return this.rules || []; } } -/** - * Only available within kubernetes cluster pages - */ -let clusterRoleApi: KubeApi; - -if (isClusterPageContext()) { // initialize automatically only when within a cluster iframe/context - clusterRoleApi = new KubeApi({ - objectConstructor: ClusterRole, - }); +export class ClusterRoleApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ClusterRole, + }); + } } - -export { - clusterRoleApi, -}; diff --git a/src/common/k8s-api/endpoints/cluster.api.injectable.ts b/src/common/k8s-api/endpoints/cluster.api.injectable.ts new file mode 100644 index 0000000000..fd82ae93f3 --- /dev/null +++ b/src/common/k8s-api/endpoints/cluster.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ClusterApi } from "./cluster.api"; + +const clusterApiInjectable = getInjectable({ + id: "cluster-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "clusterApi is only available in certain environments"); + + return new ClusterApi(); + }, +}); + +export default clusterApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster.api.ts b/src/common/k8s-api/endpoints/cluster.api.ts index 377fb47207..53e12800b0 100644 --- a/src/common/k8s-api/endpoints/cluster.api.ts +++ b/src/common/k8s-api/endpoints/cluster.api.ts @@ -3,18 +3,25 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { IMetrics, IMetricsReqParams } from "./metrics.api"; +import type { MetricData, IMetricsReqParams } from "./metrics.api"; import { metricsApi } from "./metrics.api"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export class ClusterApi extends KubeApi { static kind = "Cluster"; static namespaced = true; + + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Cluster, + }); + } } -export function getMetricsByNodeNames(nodeNames: string[], params?: IMetricsReqParams): Promise { +export function getMetricsByNodeNames(nodeNames: string[], params?: IMetricsReqParams): Promise { const nodes = nodeNames.join("|"); const opts = { category: "cluster", nodes }; @@ -45,20 +52,19 @@ export enum ClusterStatus { ERROR = "Error", } -export interface IClusterMetrics { - [metric: string]: T; - memoryUsage: T; - memoryRequests: T; - memoryLimits: T; - memoryCapacity: T; - cpuUsage: T; - cpuRequests: T; - cpuLimits: T; - cpuCapacity: T; - podUsage: T; - podCapacity: T; - fsSize: T; - fsUsage: T; +export interface ClusterMetricData extends Partial> { + memoryUsage: MetricData; + memoryRequests: MetricData; + memoryLimits: MetricData; + memoryCapacity: MetricData; + cpuUsage: MetricData; + cpuRequests: MetricData; + cpuLimits: MetricData; + cpuCapacity: MetricData; + podUsage: MetricData; + podCapacity: MetricData; + fsSize: MetricData; + fsUsage: MetricData; } export interface Cluster { @@ -107,18 +113,3 @@ export class Cluster extends KubeObject { return ClusterStatus.ACTIVE; } } - -/** - * Only available within kubernetes cluster pages - */ -let clusterApi: ClusterApi; - -if (isClusterPageContext()) { // initialize automatically only when within a cluster iframe/context - clusterApi = new ClusterApi({ - objectConstructor: Cluster, - }); -} - -export { - clusterApi, -}; diff --git a/src/common/k8s-api/endpoints/component-status.api.injectable.ts b/src/common/k8s-api/endpoints/component-status.api.injectable.ts new file mode 100644 index 0000000000..fbe4b7f176 --- /dev/null +++ b/src/common/k8s-api/endpoints/component-status.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ComponentStatusApi } from "./component-status.api"; + +const componentStatusApiInjectable = getInjectable({ + id: "component-status-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "componentStatusApi is only available in certain environments"); + + return new ComponentStatusApi(); + }, +}); + +export default componentStatusApiInjectable; diff --git a/src/common/k8s-api/endpoints/component-status.api.ts b/src/common/k8s-api/endpoints/component-status.api.ts index 30f272400a..5335207b71 100644 --- a/src/common/k8s-api/endpoints/component-status.api.ts +++ b/src/common/k8s-api/endpoints/component-status.api.ts @@ -4,16 +4,17 @@ */ import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -export interface IComponentStatusCondition { +export interface ComponentStatusCondition { type: string; status: string; message: string; } export interface ComponentStatus { - conditions: IComponentStatusCondition[]; + conditions: ComponentStatusCondition[]; } export class ComponentStatus extends KubeObject { @@ -26,6 +27,11 @@ export class ComponentStatus extends KubeObject { } } -export const componentStatusApi = new KubeApi({ - objectConstructor: ComponentStatus, -}); +export class ComponentStatusApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ComponentStatus, + }); + } +} diff --git a/src/common/k8s-api/endpoints/config-map.api.injectable.ts b/src/common/k8s-api/endpoints/config-map.api.injectable.ts new file mode 100644 index 0000000000..715e1ba728 --- /dev/null +++ b/src/common/k8s-api/endpoints/config-map.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ConfigMapApi } from "./config-map.api"; + +const configMapApiInjectable = getInjectable({ + id: "config-map-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "configMapApi is only available in certain environments"); + + return new ConfigMapApi(); + }, +}); + +export default configMapApiInjectable; diff --git a/src/common/k8s-api/endpoints/config-map.api.ts b/src/common/k8s-api/endpoints/config-map.api.ts new file mode 100644 index 0000000000..5577c690f7 --- /dev/null +++ b/src/common/k8s-api/endpoints/config-map.api.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { autoBind } from "../../utils"; + +export interface ConfigMapData extends KubeJsonApiData, void, void> { + data?: Partial>; + binaryData?: Partial>; + immutable?: boolean; +} + +export class ConfigMap extends KubeObject { + static kind = "ConfigMap"; + static namespaced = true; + static apiBase = "/api/v1/configmaps"; + + data: Partial>; + binaryData: Partial>; + immutable?: boolean; + + constructor({ data, binaryData, immutable, ...rest }: ConfigMapData) { + super(rest); + autoBind(this); + + this.data = data ?? {}; + this.binaryData = binaryData ?? {}; + this.immutable = immutable; + } + + getKeys(): string[] { + return Object.keys(this.data); + } +} + +export class ConfigMapApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: ConfigMap, + ...opts ?? {}, + }); + } +} diff --git a/src/common/k8s-api/endpoints/configmap.api.ts b/src/common/k8s-api/endpoints/configmap.api.ts deleted file mode 100644 index 82bbc47bf7..0000000000 --- a/src/common/k8s-api/endpoints/configmap.api.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObject } from "../kube-object"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { KubeApi } from "../kube-api"; -import { autoBind } from "../../utils"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export interface ConfigMap { - data: { - [param: string]: string; - }; -} - -export class ConfigMap extends KubeObject { - static kind = "ConfigMap"; - static namespaced = true; - static apiBase = "/api/v1/configmaps"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - - this.data ??= {}; - } - - getKeys(): string[] { - return Object.keys(this.data); - } -} - -/** - * Only available within kubernetes cluster pages - */ -let configMapApi: KubeApi; - -if (isClusterPageContext()) { - configMapApi = new KubeApi({ - objectConstructor: ConfigMap, - }); -} - -export { - configMapApi, -}; diff --git a/src/common/k8s-api/endpoints/cron-job.api.injectable.ts b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts new file mode 100644 index 0000000000..9390d5d4c8 --- /dev/null +++ b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { CronJobApi } from "./cron-job.api"; + +const cronJobApiInjectable = getInjectable({ + id: "cron-job-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "cronJobApi is only available in certain environments"); + + return new CronJobApi(); + }, +}); + +export default cronJobApiInjectable; diff --git a/src/common/k8s-api/endpoints/cron-job.api.ts b/src/common/k8s-api/endpoints/cron-job.api.ts index cf50780b0f..5c3dda4d7d 100644 --- a/src/common/k8s-api/endpoints/cron-job.api.ts +++ b/src/common/k8s-api/endpoints/cron-job.api.ts @@ -4,15 +4,21 @@ */ import moment from "moment"; +import type { KubeObjectScope, ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import type { IPodContainer } from "./pods.api"; import { formatDuration } from "../../utils/formatDuration"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { JobTemplateSpec } from "./types/job-template-spec"; export class CronJobApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: CronJob, + }); + } + suspend(params: { namespace: string; name: string }) { return this.request.patch(this.getUrl(params), { data: { @@ -44,61 +50,33 @@ export class CronJobApi extends KubeApi { } } -export interface CronJob { - spec: { - schedule: string; - concurrencyPolicy: string; - suspend: boolean; - jobTemplate: { - metadata: { - creationTimestamp?: string; - labels?: { - [key: string]: string; - }; - annotations?: { - [key: string]: string; - }; - }; - spec: { - template: { - metadata: { - creationTimestamp?: string; - }; - spec: { - containers: IPodContainer[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - hostPID: boolean; - schedulerName: string; - }; - }; - }; - }; - successfulJobsHistoryLimit: number; - failedJobsHistoryLimit: number; - }; - status: { - lastScheduleTime?: string; - }; +export interface CronJobSpec { + concurrencyPolicy?: string; + failedJobsHistoryLimit?: number; + jobTemplate?: JobTemplateSpec; + schedule: string; + startingDeadlineSeconds?: number; + successfulJobsHistoryLimit?: number; + suspend?: boolean; } -export class CronJob extends KubeObject { - static kind = "CronJob"; - static namespaced = true; - static apiBase = "/apis/batch/v1beta1/cronjobs"; +export interface CronJobStatus { + lastScheduleTime?: string; + lastSuccessfulTime?: string; + active?: ObjectReference[]; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class CronJob extends KubeObject { + static readonly kind = "CronJob"; + static readonly namespaced = true; + static readonly apiBase = "/apis/batch/v1beta1/cronjobs"; getSuspendFlag() { - return this.spec.suspend.toString(); + return (this.spec.suspend ?? false).toString(); } getLastScheduleTime() { - if (!this.status.lastScheduleTime) return "-"; + if (!this.status?.lastScheduleTime) return "-"; const diff = moment().diff(this.status.lastScheduleTime); return formatDuration(diff, true); @@ -124,18 +102,3 @@ export class CronJob extends KubeObject { return this.spec.suspend; } } - -/** - * Only available within kubernetes cluster pages - */ -let cronJobApi: CronJobApi; - -if (isClusterPageContext()) { - cronJobApi = new CronJobApi({ - objectConstructor: CronJob, - }); -} - -export { - cronJobApi, -}; diff --git a/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts b/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts new file mode 100644 index 0000000000..abe728850c --- /dev/null +++ b/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { CustomResourceDefinitionApi } from "./custom-resource-definition.api"; + +const customResourceDefinitionApiInjectable = getInjectable({ + id: "custom-resource-definition-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "customResourceDefinitionApi is only available in certain environments"); + + return new CustomResourceDefinitionApi(); + }, +}); + +export default customResourceDefinitionApiInjectable; diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/custom-resource-definition.api.ts similarity index 62% rename from src/common/k8s-api/endpoints/crd.api.ts rename to src/common/k8s-api/endpoints/custom-resource-definition.api.ts index f668c819f3..90485a5ab8 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/custom-resource-definition.api.ts @@ -3,19 +3,21 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { KubeCreationError, KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { KubeJsonApiData } from "../kube-json-api"; import { getLegacyGlobalDiForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import customResourcesRouteInjectable from "../../front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable"; import { buildURL } from "../../utils/buildUrl"; +import type { BaseKubeObjectCondition, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { JSONSchemaProps } from "./types/json-schema-props"; interface AdditionalPrinterColumnsCommon { name: string; type: "integer" | "number" | "string" | "boolean" | "date"; - priority: number; - description: string; + priority?: number; + format?: "int32" | "int64" | "float" | "double" | "byte" | "binary" | "date" | "date-time" | "password"; + description?: string; } export type AdditionalPrinterColumnsV1 = AdditionalPrinterColumnsCommon & { @@ -26,80 +28,90 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & { JSONPath: string; }; -export interface CRDVersion { +export interface CustomResourceValidation { + openAPIV3Schema?: JSONSchemaProps; +} + +export interface CustomResourceDefinitionVersion { name: string; served: boolean; storage: boolean; - schema?: object; // required in v1 but not present in v1beta + schema?: CustomResourceValidation; // required in v1 but not present in v1beta additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; } +export interface CustomResourceDefinitionNames { + categories?: string[]; + kind: string; + listKind?: string; + plural: string; + shortNames?: string[]; + singular?: string; +} + +export interface CustomResourceConversion { + strategy?: string; + webhook?: WebhookConversion; +} + +export interface WebhookConversion { + clientConfig?: WebhookClientConfig[]; + conversionReviewVersions: string[]; +} + +export interface WebhookClientConfig { + caBundle?: string; + url?: string; + service?: ServiceReference; +} + +export interface ServiceReference { + name: string; + namespace: string; + path?: string; + port?: number; +} + export interface CustomResourceDefinitionSpec { group: string; /** * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 */ version?: string; - names: { - plural: string; - singular: string; - kind: string; - listKind: string; - }; + names: CustomResourceDefinitionNames; scope: "Namespaced" | "Cluster"; /** * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 */ validation?: object; - versions?: CRDVersion[]; - conversion: { - strategy?: string; - webhook?: any; - }; + versions?: CustomResourceDefinitionVersion[]; + conversion?: CustomResourceConversion; /** * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 */ additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; + preserveUnknownFields?: boolean; } -export interface CustomResourceDefinition { - spec: CustomResourceDefinitionSpec; - status: { - conditions: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type?: string; - }[]; - acceptedNames: { - plural: string; - singular: string; - kind: string; - shortNames: string[]; - listKind: string; - }; - storedVersions: string[]; - }; +export interface CustomResourceDefinitionConditionAcceptedNames { + plural: string; + singular: string; + kind: string; + shortNames: string[]; + listKind: string; } -export interface CRDApiData extends KubeJsonApiData { - spec: object; // TODO: make better +export interface CustomResourceDefinitionStatus { + conditions?: BaseKubeObjectCondition[]; + acceptedNames: CustomResourceDefinitionConditionAcceptedNames; + storedVersions: string[]; } -export class CustomResourceDefinition extends KubeObject { +export class CustomResourceDefinition extends KubeObject { static kind = "CustomResourceDefinition"; static namespaced = false; static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions"; - constructor(data: CRDApiData) { - super(data); - - if (!data.spec || typeof data.spec !== "object") { - throw new KubeCreationError("Cannot create a CustomResourceDefinition from an object without spec", data); - } - } - getResourceUrl() { const di = getLegacyGlobalDiForExtensionApi(); @@ -141,12 +153,12 @@ export class CustomResourceDefinition extends KubeObject { return this.spec.scope; } - getPreferedVersion(): CRDVersion { + getPreferedVersion(): CustomResourceDefinitionVersion { const { apiVersion } = this; switch (apiVersion) { case "apiextensions.k8s.io/v1": - for (const version of this.spec.versions) { + for (const version of this.spec.versions ?? []) { if (version.storage) { return version; } @@ -158,7 +170,8 @@ export class CustomResourceDefinition extends KubeObject { const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); return { - name: this.spec.version, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + name: this.spec.version!, served: true, storage: true, schema: this.spec.validation, @@ -179,7 +192,7 @@ export class CustomResourceDefinition extends KubeObject { } getStoredVersions() { - return this.status.storedVersions.join(", "); + return this.status?.storedVersions.join(", ") ?? ""; } getNames() { @@ -216,18 +229,12 @@ export class CustomResourceDefinition extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let crdApi: KubeApi; - -if (isClusterPageContext()) { - crdApi = new KubeApi({ - objectConstructor: CustomResourceDefinition, - checkPreferredVersion: true, - }); +export class CustomResourceDefinitionApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: CustomResourceDefinition, + checkPreferredVersion: true, + ...opts, + }); + } } - -export { - crdApi, -}; diff --git a/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts b/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts new file mode 100644 index 0000000000..346742c996 --- /dev/null +++ b/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { DaemonSetApi } from "./daemon-set.api"; + +const daemonSetApiInjectable = getInjectable({ + id: "daemon-set-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "daemonSetApi is only available in certain environements"); + + return new DaemonSetApi(); + }, +}); + +export default daemonSetApiInjectable; diff --git a/src/common/k8s-api/endpoints/daemon-set.api.ts b/src/common/k8s-api/endpoints/daemon-set.api.ts index ad12b44931..449e021495 100644 --- a/src/common/k8s-api/endpoints/daemon-set.api.ts +++ b/src/common/k8s-api/endpoints/daemon-set.api.ts @@ -3,88 +3,91 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import type { IPodContainer, IPodMetrics } from "./pods.api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { PodMetricData } from "./pod.api"; +import type { KubeObjectScope, KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; -export class DaemonSet extends WorkloadKubeObject { +export interface RollingUpdateDaemonSet { + maxUnavailable?: number | string; + maxSurge?: number | string; +} + +export interface DaemonSetUpdateStrategy { + type: string; + rollingUpdate: RollingUpdateDaemonSet; +} + +export interface DaemonSetSpec { + selector: LabelSelector; + template: PodTemplateSpec; + updateStrategy: DaemonSetUpdateStrategy; + minReadySeconds?: number; + revisionHistoryLimit?: number; +} + +export interface DaemonSetStatus extends KubeObjectStatus { + collisionCount?: number; + currentNumberScheduled: number; + desiredNumberScheduled: number; + numberAvailable?: number; + numberMisscheduled: number; + numberReady: number; + numberUnavailable?: number; + observedGeneration?: number; + updatedNumberScheduled?: number; +} + +export class DaemonSet extends KubeObject { static kind = "DaemonSet"; static namespaced = true; static apiBase = "/apis/apps/v1/daemonsets"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - selector: LabelSelector; - template: { - metadata: { - creationTimestamp?: string; - labels: { - name: string; - }; - }; - spec: { - containers: IPodContainer[]; - initContainers?: IPodContainer[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - hostPID: boolean; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - securityContext: {}; - schedulerName: string; - tolerations: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - }; - }; - updateStrategy: { - type: string; - rollingUpdate: { - maxUnavailable: number; - }; - }; - revisionHistoryLimit: number; - }; - declare status: { - currentNumberScheduled: number; - numberMisscheduled: number; - desiredNumberScheduled: number; - numberReady: number; - observedGeneration: number; - updatedNumberScheduled: number; - numberAvailable: number; - numberUnavailable: number; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata?.labels); + } + + getTolerations() { + return this.spec.template.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec?.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); - const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", []); + const containers = this.spec.template?.spec?.containers ?? []; + const initContainers = this.spec.template?.spec?.initContainers ?? []; return [...containers, ...initContainers].map(container => container.image); } } export class DaemonSetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: DaemonSet, + }); + } } -export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise { +export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise { const podSelector = daemonsets.map(daemonset => `${daemonset.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -100,18 +103,3 @@ export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: stri namespace, }); } - -/** - * Only available within kubernetes cluster pages - */ -let daemonSetApi: DaemonSetApi; - -if (isClusterPageContext()) { - daemonSetApi = new DaemonSetApi({ - objectConstructor: DaemonSet, - }); -} - -export { - daemonSetApi, -}; diff --git a/src/common/k8s-api/endpoints/deployment.api.injectable.ts b/src/common/k8s-api/endpoints/deployment.api.injectable.ts new file mode 100644 index 0000000000..5b94d949aa --- /dev/null +++ b/src/common/k8s-api/endpoints/deployment.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { DeploymentApi } from "./deployment.api"; + +const deploymentApiInjectable = getInjectable({ + id: "deployment-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "deploymentApi is only available in certain environments"); + + return new DeploymentApi(); + }, +}); + +export default deploymentApiInjectable; diff --git a/src/common/k8s-api/endpoints/deployment.api.ts b/src/common/k8s-api/endpoints/deployment.api.ts index 324310fa74..c877669ba4 100644 --- a/src/common/k8s-api/endpoints/deployment.api.ts +++ b/src/common/k8s-api/endpoints/deployment.api.ts @@ -5,25 +5,34 @@ import moment from "moment"; -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { PodMetricData, PodSpec } from "./pod.api"; +import type { KubeObjectScope, KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { hasTypedProperty, isNumber, isObject } from "../../utils"; export class DeploymentApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: Deployment, + ...opts ?? {}, + }); + } + protected getScaleApiUrl(params: { namespace: string; name: string }) { return `${this.getUrl(params)}/scale`; } - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + if (isObject(status) && hasTypedProperty(status, "replicas", isNumber)) { + return status.replicas; + } + + return 0; } scale(params: { namespace: string; name: string }, replicas: number) { @@ -61,7 +70,7 @@ export class DeploymentApi extends KubeApi { } } -export function getMetricsForDeployments(deployments: Deployment[], namespace: string, selector = ""): Promise { +export function getMetricsForDeployments(deployments: Deployment[], namespace: string, selector = ""): Promise { const podSelector = deployments.map(deployment => `${deployment.getName()}-[[:alnum:]]{9,}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -78,136 +87,66 @@ export function getMetricsForDeployments(deployments: Deployment[], namespace: s }); } -interface IContainerProbe { - httpGet?: { - path?: string; - port: number; - scheme: string; - host?: string; +export interface DeploymentSpec { + replicas: number; + selector: LabelSelector; + template: { + metadata: { + creationTimestamp?: string; + labels: Partial>; + annotations?: Partial>; + }; + spec: PodSpec; }; - exec?: { - command: string[]; + strategy: { + type: string; + rollingUpdate: { + maxUnavailable: number; + maxSurge: number; + }; }; - tcpSocket?: { - port: number; - }; - initialDelaySeconds?: number; - timeoutSeconds?: number; - periodSeconds?: number; - successThreshold?: number; - failureThreshold?: number; } -export class Deployment extends WorkloadKubeObject { +export interface DeploymentStatus extends KubeObjectStatus { + observedGeneration: number; + replicas: number; + updatedReplicas: number; + readyReplicas: number; + availableReplicas?: number; + unavailableReplicas?: number; +} + +export class Deployment extends KubeObject { static kind = "Deployment"; static namespaced = true; static apiBase = "/apis/apps/v1/deployments"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - replicas: number; - selector: LabelSelector; - template: { - metadata: { - creationTimestamp?: string; - labels: { [app: string]: string }; - annotations?: { [app: string]: string }; - }; - spec: { - containers: { - name: string; - image: string; - args?: string[]; - ports?: { - name: string; - containerPort: number; - protocol: string; - }[]; - env?: { - name: string; - value: string; - }[]; - resources: { - limits?: { - cpu: string; - memory: string; - }; - requests: { - cpu: string; - memory: string; - }; - }; - volumeMounts?: { - name: string; - mountPath: string; - }[]; - livenessProbe?: IContainerProbe; - readinessProbe?: IContainerProbe; - startupProbe?: IContainerProbe; - terminationMessagePath: string; - terminationMessagePolicy: string; - imagePullPolicy: string; - }[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - serviceAccountName: string; - serviceAccount: string; - securityContext: {}; - schedulerName: string; - tolerations?: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - volumes?: { - name: string; - configMap: { - name: string; - defaultMode: number; - optional: boolean; - }; - }[]; - }; - }; - strategy: { - type: string; - rollingUpdate: { - maxUnavailable: number; - maxSurge: number; - }; - }; - }; - declare status: { - observedGeneration: number; - replicas: number; - updatedReplicas: number; - readyReplicas: number; - availableReplicas?: number; - unavailableReplicas?: number; - conditions: { - type: string; - status: string; - lastUpdateTime: string; - lastTransitionTime: string; - reason: string; - message: string; - }[]; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata.labels); + } + + getTolerations() { + return this.spec.template.spec.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getConditions(activeOnly = false) { - const { conditions } = this.status; - - if (!conditions) return []; + const { conditions = [] } = this.status ?? {}; if (activeOnly) { return conditions.filter(c => c.status === "True"); @@ -217,22 +156,12 @@ export class Deployment extends WorkloadKubeObject { } getConditionsText(activeOnly = true) { - return this.getConditions(activeOnly).map(({ type }) => type).join(" "); + return this.getConditions(activeOnly) + .map(({ type }) => type) + .join(" "); } getReplicas() { return this.spec.replicas || 0; } } - -let deploymentApi: DeploymentApi; - -if (isClusterPageContext()) { - deploymentApi = new DeploymentApi({ - objectConstructor: Deployment, - }); -} - -export { - deploymentApi, -}; diff --git a/src/common/k8s-api/endpoints/endpoint.api.injectable.ts b/src/common/k8s-api/endpoints/endpoint.api.injectable.ts new file mode 100644 index 0000000000..44a6595b6c --- /dev/null +++ b/src/common/k8s-api/endpoints/endpoint.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { EndpointsApi } from "./endpoint.api"; + +const endpointsApiInjectable = getInjectable({ + id: "endpoints-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "endpointsApi is only available in certain environments"); + + return new EndpointsApi(); + }, +}); + +export default endpointsApiInjectable; diff --git a/src/common/k8s-api/endpoints/endpoint.api.ts b/src/common/k8s-api/endpoints/endpoint.api.ts index 9e025eb158..38af0044a5 100644 --- a/src/common/k8s-api/endpoints/endpoint.api.ts +++ b/src/common/k8s-api/endpoints/endpoint.api.ts @@ -4,144 +4,114 @@ */ import { autoBind } from "../../utils"; +import type { KubeObjectMetadata, KubeObjectScope, ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { get } from "lodash"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface IEndpointPort { +export function formatEndpointSubset(subset: EndpointSubset): string { + const { addresses, ports } = subset; + + if (!addresses || !ports) { + return ""; + } + + return addresses + .map(address => ( + ports + .map(port => `${address.ip}:${port.port}`) + .join(", ") + )) + .join(", "); + +} + +export interface ForZone { + name: string; +} + +export interface EndpointHints { + forZones?: ForZone[]; +} + +export interface EndpointConditions { + ready?: boolean; + serving?: boolean; + terminating?: boolean; +} + +export interface EndpointData { + addresses: string[]; + conditions?: EndpointConditions; + hints?: EndpointHints; + hostname?: string; + nodeName?: string; + targetRef?: ObjectReference; + zone?: string; +} + +export interface EndpointPort { + appProtocol?: string; name?: string; - protocol: string; + protocol?: string; port: number; } -export interface IEndpointAddress { - hostname: string; +export interface EndpointAddress { + hostname?: string; ip: string; - nodeName: string; + nodeName?: string; + targetRef?: ObjectReference; } -export interface IEndpointSubset { - addresses: IEndpointAddress[]; - notReadyAddresses: IEndpointAddress[]; - ports: IEndpointPort[]; +export interface EndpointSubset { + addresses?: EndpointAddress[]; + notReadyAddresses?: EndpointAddress[]; + ports?: EndpointPort[]; } -interface ITargetRef { - kind: string; - namespace: string; - name: string; - uid: string; - resourceVersion: string; - apiVersion: string; +export interface EndpointsData extends KubeJsonApiData, void, void> { + subsets?: EndpointSubset[]; } -export class EndpointAddress implements IEndpointAddress { - hostname: string; - ip: string; - nodeName: string; - targetRef?: { - kind: string; - namespace: string; - name: string; - uid: string; - resourceVersion: string; - }; - - static create(data: IEndpointAddress): EndpointAddress { - return new EndpointAddress(data); - } - - constructor(data: IEndpointAddress) { - Object.assign(this, data); - } - - getId() { - return this.ip; - } - - getName() { - return this.hostname; - } - - getTargetRef(): ITargetRef { - if (this.targetRef) { - return Object.assign(this.targetRef, { apiVersion: "v1" }); - } else { - return null; - } - } -} - -export class EndpointSubset implements IEndpointSubset { - addresses: IEndpointAddress[]; - notReadyAddresses: IEndpointAddress[]; - ports: IEndpointPort[]; - - constructor(data: IEndpointSubset) { - this.addresses = get(data, "addresses", []); - this.notReadyAddresses = get(data, "notReadyAddresses", []); - this.ports = get(data, "ports", []); - } - - getAddresses(): EndpointAddress[] { - return this.addresses.map(EndpointAddress.create); - } - - getNotReadyAddresses(): EndpointAddress[] { - return this.notReadyAddresses.map(EndpointAddress.create); - } - - toString(): string { - return this.addresses - .map(address => ( - this.ports - .map(port => `${address.ip}:${port.port}`) - .join(", ") - )) - .join(", "); - } -} - -export interface Endpoint { - subsets: IEndpointSubset[]; -} - -export class Endpoint extends KubeObject { +export class Endpoints extends KubeObject { static kind = "Endpoints"; static namespaced = true; static apiBase = "/api/v1/endpoints"; - constructor(data: KubeJsonApiData) { - super(data); + subsets?: EndpointSubset[]; + + constructor({ subsets, ...rest }: EndpointsData) { + super(rest); autoBind(this); + this.subsets = subsets; } - getEndpointSubsets(): EndpointSubset[] { - const subsets = this.subsets || []; - - return subsets.map(s => new EndpointSubset(s)); + getEndpointSubsets(): Required[] { + return this.subsets?.map(({ + addresses = [], + notReadyAddresses = [], + ports = [], + }) => ({ + addresses, + notReadyAddresses, + ports, + })) ?? []; } toString(): string { - if(this.subsets) { - return this.getEndpointSubsets().map(es => es.toString()).join(", "); - } else { - return ""; - } + return this.getEndpointSubsets() + .map(formatEndpointSubset) + .join(", ") || ""; } - } -let endpointApi: KubeApi; - -if (isClusterPageContext()) { - endpointApi = new KubeApi({ - objectConstructor: Endpoint, - }); +export class EndpointsApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: Endpoints, + ...opts, + }); + } } - -export { - endpointApi, -}; diff --git a/src/common/k8s-api/endpoints/events.api.injectable.ts b/src/common/k8s-api/endpoints/events.api.injectable.ts new file mode 100644 index 0000000000..c4fbe81224 --- /dev/null +++ b/src/common/k8s-api/endpoints/events.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { KubeEventApi } from "./events.api"; + +const kubeEventApiInjectable = getInjectable({ + id: "kube-event-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "kubeEventApi is only available in certain environments"); + + return new KubeEventApi(); + }, +}); + +export default kubeEventApiInjectable; diff --git a/src/common/k8s-api/endpoints/events.api.ts b/src/common/k8s-api/endpoints/events.api.ts index f2f73ce383..8e6bc843b1 100644 --- a/src/common/k8s-api/endpoints/events.api.ts +++ b/src/common/k8s-api/endpoints/events.api.ts @@ -4,34 +4,38 @@ */ import moment from "moment"; +import type { KubeObjectMetadata, ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; import { formatDuration } from "../../utils/formatDuration"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; -export interface KubeEvent { - involvedObject: { - kind: string; - namespace: string; - name: string; - uid: string; - apiVersion: string; - resourceVersion: string; - fieldPath: string; - }; - reason: string; - message: string; - source: { - component: string; - host: string; - }; - firstTimestamp: string; - lastTimestamp: string; - count: number; - type: "Normal" | "Warning" | string; - eventTime: null; - reportingComponent: string; - reportingInstance: string; +export interface EventSeries { + count?: number; + lastObservedTime?: string; +} + +export interface EventSource { + component?: string; + host?: string; +} + +export interface KubeEventData extends KubeJsonApiData { + action?: string; + count?: number; + eventTime?: string; + firstTimestamp?: string; + involvedObject: Required; + lastTimestamp?: string; + message?: string; + reason?: string; + related?: ObjectReference; + reportingComponent?: string; + reportingInstance?: string; + series?: EventSeries; + source?: EventSource; + type?: string; } export class KubeEvent extends KubeObject { @@ -39,14 +43,73 @@ export class KubeEvent extends KubeObject { static namespaced = true; static apiBase = "/api/v1/events"; + action?: string; + count?: number; + eventTime?: string; + firstTimestamp?: string; + involvedObject: Required; + lastTimestamp?: string; + message?: string; + reason?: string; + related?: ObjectReference; + reportingComponent?: string; + reportingInstance?: string; + series?: EventSeries; + source?: EventSource; + + /** + * Current supported values are: + * - "Normal" + * - "Warning" + */ + type?: string; + + constructor({ + action, + count, + eventTime, + firstTimestamp, + involvedObject, + lastTimestamp, + message, + reason, + related, + reportingComponent, + reportingInstance, + series, + source, + type, + ...rest + }: KubeEventData) { + super(rest); + this.action = action; + this.count = count; + this.eventTime = eventTime; + this.firstTimestamp = firstTimestamp; + this.involvedObject = involvedObject; + this.lastTimestamp = lastTimestamp; + this.message = message; + this.reason = reason; + this.related = related; + this.reportingComponent = reportingComponent; + this.reportingInstance = reportingInstance; + this.series = series; + this.source = source; + this.type = type; + } + isWarning() { return this.type === "Warning"; } getSource() { - const { component, host } = this.source; + if (!this.source?.component) { + return ""; + } - return `${component} ${host || ""}`; + const { component, host = "" } = this.source; + + return `${component} ${host}`; } /** @@ -68,14 +131,11 @@ export class KubeEvent extends KubeObject { } } -let eventApi: KubeApi; - -if (isClusterPageContext()) { - eventApi = new KubeApi({ - objectConstructor: KubeEvent, - }); +export class KubeEventApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: KubeEvent, + ...opts, + }); + } } - -export { - eventApi, -}; diff --git a/src/common/k8s-api/endpoints/helm-charts.api.ts b/src/common/k8s-api/endpoints/helm-charts.api.ts index 163f90ef36..2690e95afb 100644 --- a/src/common/k8s-api/endpoints/helm-charts.api.ts +++ b/src/common/k8s-api/endpoints/helm-charts.api.ts @@ -7,7 +7,7 @@ import { compile } from "path-to-regexp"; import { apiBase } from "../index"; import { stringify } from "querystring"; import type { RequestInit } from "node-fetch"; -import { autoBind, bifurcateArray } from "../../utils"; +import { autoBind, bifurcateArray, isDefined } from "../../utils"; import Joi from "joi"; export type RepoHelmChartList = Record; @@ -30,9 +30,9 @@ export async function listCharts(): Promise { return Object .values(data) - .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), new Array()) .map(([chart]) => HelmChart.create(chart, { onError: "log" })) - .filter(Boolean); + .filter(isDefined); } export interface GetChartDetailsOptions { @@ -51,7 +51,7 @@ export async function getChartDetails(repo: string, name: string, { version, req const path = endpoint({ repo, name }); const { readme, ...data } = await apiBase.get(`${path}?${stringify({ version })}`, undefined, reqInit); - const versions = data.versions.map(version => HelmChart.create(version, { onError: "log" })).filter(Boolean); + const versions = data.versions.map(version => HelmChart.create(version, { onError: "log" })).filter(isDefined); return { readme, @@ -242,7 +242,7 @@ export interface RawHelmChartDependency { export type HelmChartDependency = Required> & Pick; -export interface HelmChart { +export interface HelmChartData { apiVersion: string; name: string; version: string; @@ -266,8 +266,30 @@ export interface HelmChart { tillerVersion?: string; } -export class HelmChart { - private constructor(value: HelmChart) { +export class HelmChart implements HelmChartData { + apiVersion: string; + name: string; + version: string; + repo: string; + created: string; + description: string; + keywords: string[]; + sources: string[]; + urls: string[]; + annotations: Record; + dependencies: HelmChartDependency[]; + maintainers: HelmChartMaintainer[]; + deprecated: boolean; + kubeVersion?: string; + digest?: string; + home?: string; + engine?: string; + icon?: string; + appVersion?: string; + type?: string; + tillerVersion?: string; + + private constructor(value: HelmChart | HelmChartData) { this.apiVersion = value.apiVersion; this.name = value.name; this.version = value.version; @@ -294,25 +316,25 @@ export class HelmChart { } static create(data: RawHelmChart, { onError = "throw" }: HelmChartCreateOpts = {}): HelmChart | undefined { - const { value, error } = helmChartValidator.validate(data, { + const result = helmChartValidator.validate(data, { abortEarly: false, }); - if (!error) { - return new HelmChart(value); + if (!result.error) { + return new HelmChart(result.value); } - const [actualErrors, unknownDetails] = bifurcateArray(error.details, ({ type }) => type === "object.unknown"); + const [actualErrors, unknownDetails] = bifurcateArray(result.error.details, ({ type }) => type === "object.unknown"); if (unknownDetails.length > 0) { console.warn("HelmChart data has unexpected fields", { original: data, unknownFields: unknownDetails.flatMap(d => d.path) }); } if (actualErrors.length === 0) { - return new HelmChart(value); + return new HelmChart(result.value as unknown as HelmChartData); } - const validationError = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, error._original); + const validationError = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, result.error._original); if (onError === "throw") { throw validationError; @@ -347,7 +369,7 @@ export class HelmChart { return this.icon; } - getHome(): string { + getHome(): string | undefined { return this.home; } diff --git a/src/common/k8s-api/endpoints/helm-releases.api.ts b/src/common/k8s-api/endpoints/helm-releases.api.ts index 141f884c60..b3922276bd 100644 --- a/src/common/k8s-api/endpoints/helm-releases.api.ts +++ b/src/common/k8s-api/endpoints/helm-releases.api.ts @@ -9,12 +9,12 @@ import capitalize from "lodash/capitalize"; import { apiBase } from "../index"; import { helmChartStore } from "../../../renderer/components/+helm-charts/helm-chart.store"; import type { ItemObject } from "../../item.store"; -import { KubeObject } from "../kube-object"; import type { JsonApiData } from "../json-api"; import { buildURLPositional } from "../../utils/buildUrl"; import type { KubeJsonApiData } from "../kube-json-api"; -interface IReleasePayload { +export interface HelmReleaseDetails { + resources: KubeJsonApiData[]; name: string; namespace: string; version: string; @@ -30,15 +30,7 @@ interface IReleasePayload { }; } -interface IReleaseRawDetails extends IReleasePayload { - resources: KubeJsonApiData[]; -} - -export interface IReleaseDetails extends IReleasePayload { - resources: KubeObject[]; -} - -export interface IReleaseCreatePayload { +export interface HelmReleaseCreatePayload { name?: string; repo: string; chart: string; @@ -47,19 +39,19 @@ export interface IReleaseCreatePayload { values: string; } -export interface IReleaseUpdatePayload { +export interface HelmReleaseUpdatePayload { repo: string; chart: string; version: string; values: string; } -export interface IReleaseUpdateDetails { +export interface HelmReleaseUpdateDetails { log: string; - release: IReleaseDetails; + release: HelmReleaseDetails; } -export interface IReleaseRevision { +export interface HelmReleaseRevision { revision: number; updated: string; status: string; @@ -85,18 +77,13 @@ export async function listReleases(namespace?: string): Promise { return releases.map(toHelmRelease); } -export async function getRelease(name: string, namespace: string): Promise { +export async function getRelease(name: string, namespace: string): Promise { const path = endpoint({ name, namespace }); - const { resources: rawResources, ...details } = await apiBase.get(path); - const resources = rawResources.map(KubeObject.create); - return { - ...details, - resources, - }; + return apiBase.get(path); } -export async function createRelease(payload: IReleaseCreatePayload): Promise { +export async function createRelease(payload: HelmReleaseCreatePayload): Promise { const { repo, chart: rawChart, values: rawValues, ...data } = payload; const chart = `${repo}/${rawChart}`; const values = yaml.load(rawValues); @@ -110,7 +97,7 @@ export async function createRelease(payload: IReleaseCreatePayload): Promise { +export async function updateRelease(name: string, namespace: string, payload: HelmReleaseUpdatePayload): Promise { const { repo, chart: rawChart, values: rawValues, ...data } = payload; const chart = `${repo}/${rawChart}`; const values = yaml.load(rawValues); @@ -137,7 +124,7 @@ export async function getReleaseValues(name: string, namespace: string, all?: bo return apiBase.get(path); } -export async function getReleaseHistory(name: string, namespace: string): Promise { +export async function getReleaseHistory(name: string, namespace: string): Promise { const route = "history"; const path = endpoint({ name, namespace, route }); diff --git a/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts new file mode 100644 index 0000000000..70b6458456 --- /dev/null +++ b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { HorizontalPodAutoscalerApi } from "./horizontal-pod-autoscaler.api"; + +const horizontalPodAutoscalerApiInjectable = getInjectable({ + id: "horizontal-pod-autoscaler-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "horizontalPodAutoscalerApi is only available in certain environments"); + + return new HorizontalPodAutoscalerApi(); + }, +}); + +export default horizontalPodAutoscalerApiInjectable; diff --git a/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts new file mode 100644 index 0000000000..06d5669220 --- /dev/null +++ b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts @@ -0,0 +1,261 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { BaseKubeObjectCondition, KubeObjectScope, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { OptionVarient } from "../../utils"; + +export enum HpaMetricType { + Resource = "Resource", + Pods = "Pods", + Object = "Object", + External = "External", + ContainerResource = "ContainerResource", +} + +export interface HorizontalPodAutoscalerMetricTarget { + kind: string; + name: string; + apiVersion: string; +} + +export interface ContainerResourceMetricSource { + container: string; + name: string; + targetAverageUtilization?: number; + targetAverageValue?: string; +} + +export interface ExternalMetricSource { + metricName: string; + metricSelector?: LabelSelector; + targetAverageValue?: string; + targetValue?: string; +} + +export interface ObjectMetricSource { + averageValue?: string; + metricName: string; + selector?: LabelSelector; + target: CrossVersionObjectReference; + targetValue: string; +} + +export interface PodsMetricSource { + metricName: string; + selector?: LabelSelector; + targetAverageValue: string; +} + +export interface ResourceMetricSource { + name: string; + targetAverageUtilization?: number; + targetAverageValue?: string; +} + +export interface BaseHorizontalPodAutoscalerMetricSpec { + resource: ResourceMetricSource; + object: ObjectMetricSource; + external: ExternalMetricSource; + pods: PodsMetricSource; + containerResource: ContainerResourceMetricSource; +} + +export type HorizontalPodAutoscalerMetricSpec = + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient; + +export interface CrossVersionObjectReference { + kind: string; + name: string; + apiVersion: string; +} + +export interface HorizontalPodAutoscalerSpec { + scaleTargetRef: CrossVersionObjectReference; + minReplicas?: number; + maxReplicas: number; + metrics?: HorizontalPodAutoscalerMetricSpec[]; +} + +export interface HorizontalPodAutoscalerStatus { + conditions?: BaseKubeObjectCondition[]; + currentReplicas: number; + desiredReplicas: number; + currentMetrics: HorizontalPodAutoscalerMetricSpec[]; +} + +interface MetricCurrentTarget { + current?: string; + target?: string; +} + +export class HorizontalPodAutoscaler extends KubeObject { + static readonly kind = "HorizontalPodAutoscaler"; + static readonly namespaced = true; + static readonly apiBase = "/apis/autoscaling/v2beta1/horizontalpodautoscalers"; + + getMaxPods() { + return this.spec.maxReplicas ?? 0; + } + + getMinPods() { + return this.spec.minReplicas ?? 0; + } + + getReplicas() { + return this.status?.currentReplicas ?? 0; + } + + getReadyConditions() { + return this.getConditions().filter(({ isReady }) => isReady); + } + + getConditions() { + return this.status?.conditions?.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})`, + }; + }) ?? []; + } + + getMetrics() { + return this.spec.metrics ?? []; + } + + getCurrentMetrics() { + return this.status?.currentMetrics ?? []; + } + + protected getMetricName(metric: HorizontalPodAutoscalerMetricSpec): string { + switch (metric.type) { + case HpaMetricType.Resource: + return metric.resource.name; + case HpaMetricType.Pods: + return metric.pods.metricName; + case HpaMetricType.Object: + return metric.object.metricName; + case HpaMetricType.External: + return metric.external.metricName; + case HpaMetricType.ContainerResource: + return metric.containerResource.name; + default: + return ``; + } + } + + protected getResourceMetricValue(currentMetric: ResourceMetricSource | undefined, targetMetric: ResourceMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetAverageUtilization + ? `${currentMetric.targetAverageUtilization}%` + : currentMetric?.targetAverageValue + ), + target: ( + targetMetric?.targetAverageUtilization + ? `${targetMetric.targetAverageUtilization}%` + : targetMetric?.targetAverageValue + ), + }; + } + + protected getPodsMetricValue(currentMetric: PodsMetricSource | undefined, targetMetric: PodsMetricSource): MetricCurrentTarget { + return { + current: currentMetric?.targetAverageValue, + target: targetMetric?.targetAverageValue, + }; + } + + protected getObjectMetricValue(currentMetric: ObjectMetricSource | undefined, targetMetric: ObjectMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetValue + ?? currentMetric?.averageValue + ), + target: ( + targetMetric?.targetValue + ?? targetMetric?.averageValue + ), + }; + } + + protected getExternalMetricValue(currentMetric: ExternalMetricSource | undefined, targetMetric: ExternalMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetValue + ?? currentMetric?.targetAverageValue + ), + target: ( + targetMetric?.targetValue + ?? targetMetric?.targetAverageValue + ), + }; + } + + protected getContainerResourceMetricValue(currentMetric: ContainerResourceMetricSource | undefined, targetMetric: ContainerResourceMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetAverageUtilization + ? `${currentMetric.targetAverageUtilization}%` + : currentMetric?.targetAverageValue + ), + target: ( + targetMetric?.targetAverageUtilization + ? `${targetMetric.targetAverageUtilization}%` + : targetMetric?.targetAverageValue + ), + }; + } + + protected getMetricCurrentTarget(metric: HorizontalPodAutoscalerMetricSpec): MetricCurrentTarget { + const currentMetric = this.getMetrics() + .find(m => ( + m.type === metric.type + && this.getMetricName(m) === this.getMetricName(metric) + )); + + switch (metric.type) { + case HpaMetricType.Resource: + return this.getResourceMetricValue(currentMetric?.resource, metric.resource); + case HpaMetricType.Pods: + return this.getPodsMetricValue(currentMetric?.pods, metric.pods); + case HpaMetricType.Object: + return this.getObjectMetricValue(currentMetric?.object, metric.object); + case HpaMetricType.External: + return this.getExternalMetricValue(currentMetric?.external, metric.external); + case HpaMetricType.ContainerResource: + return this.getContainerResourceMetricValue(currentMetric?.containerResource, metric.containerResource); + default: + return {}; + } + } + + getMetricValues(metric: HorizontalPodAutoscalerMetricSpec): string { + const { + current = "unknown", + target = "unknown", + } = this.getMetricCurrentTarget(metric); + + return `${current} / ${target}`; + } +} + +export class HorizontalPodAutoscalerApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: HorizontalPodAutoscaler, + ...opts ?? {}, + }); + } +} diff --git a/src/common/k8s-api/endpoints/hpa.api.ts b/src/common/k8s-api/endpoints/hpa.api.ts deleted file mode 100644 index e284f3a3e8..0000000000 --- a/src/common/k8s-api/endpoints/hpa.api.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export enum HpaMetricType { - Resource = "Resource", - Pods = "Pods", - Object = "Object", - External = "External", -} - -export type IHpaMetricData = T & { - target?: { - kind: string; - name: string; - apiVersion: string; - }; - name?: string; - metricName?: string; - currentAverageUtilization?: number; - currentAverageValue?: string; - targetAverageUtilization?: number; - targetAverageValue?: string; -}; - -export interface IHpaMetric { - [kind: string]: IHpaMetricData; - - type: HpaMetricType; - resource?: IHpaMetricData<{ name: string }>; - pods?: IHpaMetricData; - external?: IHpaMetricData; - object?: IHpaMetricData<{ - describedObject: { - apiVersion: string; - kind: string; - name: string; - }; - }>; -} - -export interface HorizontalPodAutoscaler { - spec: { - scaleTargetRef: { - kind: string; - name: string; - apiVersion: string; - }; - minReplicas: number; - maxReplicas: number; - metrics: IHpaMetric[]; - }; - status: { - currentReplicas: number; - desiredReplicas: number; - currentMetrics: IHpaMetric[]; - conditions: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type: string; - }[]; - }; -} - -export class HorizontalPodAutoscaler extends KubeObject { - static kind = "HorizontalPodAutoscaler"; - static namespaced = true; - static apiBase = "/apis/autoscaling/v2beta1/horizontalpodautoscalers"; - - getMaxPods() { - return this.spec.maxReplicas || 0; - } - - getMinPods() { - return this.spec.minReplicas || 0; - } - - getReplicas() { - return this.status.currentReplicas; - } - - getConditions() { - if (!this.status.conditions) return []; - - return this.status.conditions.map(condition => { - const { message, reason, lastTransitionTime, status } = condition; - - return { - ...condition, - isReady: status === "True", - tooltip: `${message || reason} (${lastTransitionTime})`, - }; - }); - } - - getMetrics() { - return this.spec.metrics || []; - } - - getCurrentMetrics() { - return this.status.currentMetrics || []; - } - - protected getMetricName(metric: IHpaMetric): string { - const { type, resource, pods, object, external } = metric; - - switch (type) { - case HpaMetricType.Resource: - return resource.name; - case HpaMetricType.Pods: - return pods.metricName; - case HpaMetricType.Object: - return object.metricName; - case HpaMetricType.External: - return external.metricName; - } - } - - // todo: refactor - getMetricValues(metric: IHpaMetric): string { - const metricType = metric.type.toLowerCase(); - const currentMetric = this.getCurrentMetrics().find(current => - metric.type == current.type && this.getMetricName(metric) == this.getMetricName(current), - ); - const current = currentMetric ? currentMetric[metricType] : null; - const target = metric[metricType]; - let currentValue = "unknown"; - let targetValue = "unknown"; - - if (current) { - currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue; - if (current.currentAverageUtilization) currentValue += "%"; - } - - if (target) { - targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue; - if (target.targetAverageUtilization) targetValue += "%"; - } - - return `${currentValue} / ${targetValue}`; - } -} - -let hpaApi: KubeApi; - -if (isClusterPageContext()) { - hpaApi = new KubeApi({ - objectConstructor: HorizontalPodAutoscaler, - }); -} - -export { - hpaApi, -}; diff --git a/src/common/k8s-api/endpoints/index.ts b/src/common/k8s-api/endpoints/index.ts index 0fb21635ae..5038915657 100644 --- a/src/common/k8s-api/endpoints/index.ts +++ b/src/common/k8s-api/endpoints/index.ts @@ -9,33 +9,34 @@ export * from "./cluster.api"; export * from "./cluster-role.api"; export * from "./cluster-role-binding.api"; -export * from "./configmap.api"; -export * from "./crd.api"; +export * from "./config-map.api"; +export * from "./custom-resource-definition.api"; export * from "./cron-job.api"; export * from "./daemon-set.api"; export * from "./deployment.api"; export * from "./endpoint.api"; export * from "./events.api"; -export * from "./hpa.api"; +export * from "./horizontal-pod-autoscaler.api"; export * from "./ingress.api"; export * from "./job.api"; export * from "./limit-range.api"; -export * from "./namespaces.api"; +export * from "./namespace.api"; export * from "./network-policy.api"; -export * from "./nodes.api"; +export * from "./node.api"; export * from "./persistent-volume.api"; -export * from "./persistent-volume-claims.api"; -export * from "./pods.api"; -export * from "./poddisruptionbudget.api"; +export * from "./persistent-volume-claim.api"; +export * from "./pod.api"; +export * from "./pod-disruption-budget.api"; export * from "./pod-metrics.api"; -export * from "./podsecuritypolicy.api"; +export * from "./pod-security-policy.api"; export * from "./replica-set.api"; export * from "./resource-quota.api"; export * from "./role.api"; export * from "./role-binding.api"; export * from "./secret.api"; -export * from "./selfsubjectrulesreviews.api"; +export * from "./self-subject-rules-reviews.api"; export * from "./service.api"; -export * from "./service-accounts.api"; +export * from "./service-account.api"; export * from "./stateful-set.api"; export * from "./storage-class.api"; +export * from "./legacy-globals"; diff --git a/src/common/k8s-api/endpoints/ingress.api.injectable.ts b/src/common/k8s-api/endpoints/ingress.api.injectable.ts new file mode 100644 index 0000000000..ca280508b2 --- /dev/null +++ b/src/common/k8s-api/endpoints/ingress.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { IngressApi } from "./ingress.api"; + +const ingressApiInjectable = getInjectable({ + id: "ingress-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "ingressApi is only available in certain environments"); + + return new IngressApi(); + }, +}); + +export default ingressApiInjectable; diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index f4ec20af1a..4b9c044b0a 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -3,20 +3,28 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { TypedLocalObjectReference } from "../kube-object"; +import type { KubeObjectScope, TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind, hasTypedProperty, isString, iter } from "../../utils"; -import type { IMetrics } from "./metrics.api"; +import { hasTypedProperty, isString, iter } from "../../utils"; +import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { RequireExactlyOne } from "type-fest"; export class IngressApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Ingress, + // Add fallback for Kubernetes <1.19 + checkPreferredVersion: true, + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + }); + } } -export function getMetricsForIngress(ingress: string, namespace: string): Promise { +export function getMetricsForIngress(ingress: string, namespace: string): Promise { const opts = { category: "ingress", ingress, namespace }; return metricsApi.getMetrics({ @@ -29,12 +37,11 @@ export function getMetricsForIngress(ingress: string, namespace: string): Promis }); } -export interface IIngressMetrics { - [metric: string]: T; - bytesSentSuccess: T; - bytesSentFailure: T; - requestDurationSeconds: T; - responseDurationSeconds: T; +export interface IngressMetricData extends Partial> { + bytesSentSuccess: MetricData; + bytesSentFailure: MetricData; + requestDurationSeconds: MetricData; + responseDurationSeconds: MetricData; } export interface ILoadBalancerIngress { @@ -73,7 +80,11 @@ function isExtensionsBackend(backend: IngressBackend): backend is ExtensionsBack * Format an ingress backend into the name of the service and port * @param backend The ingress target */ -export function getBackendServiceNamePort(backend: IngressBackend): string { +export function getBackendServiceNamePort(backend: IngressBackend | undefined): string { + if (!backend) { + return ""; + } + if (isExtensionsBackend(backend)) { return `${backend.serviceName}:${backend.servicePort}`; } @@ -87,60 +98,52 @@ export function getBackendServiceNamePort(backend: IngressBackend): string { return ""; } +export interface HTTPIngressPath { + pathType: "Exact" | "Prefix" | "ImplementationSpecific"; + path?: string; + backend?: IngressBackend; +} + +export interface HTTPIngressRuleValue { + paths: HTTPIngressPath[]; +} + export interface IngressRule { host?: string; - http?: { - paths: { - path?: string; - backend: IngressBackend; - }[]; - }; + http?: HTTPIngressRuleValue; } -export interface Ingress { - spec?: { - tls?: { - secretName: string; - }[]; - rules?: IngressRule[]; - // extensions/v1beta1 - backend?: ExtensionsBackend; - /** - * The default backend which is exactly on of: - * - service - * - resource - */ - defaultBackend?: RequireExactlyOne; - }; - status: { - loadBalancer: { - ingress: ILoadBalancerIngress[]; +export interface IngressSpec { + tls: { + secretName: string; + }[]; + rules?: IngressRule[]; + // extensions/v1beta1 + backend?: ExtensionsBackend; + /** + * The default backend which is exactly on of: + * - service + * - resource + */ + defaultBackend?: RequireExactlyOne; +} + +export interface IngressStatus { + loadBalancer: { + ingress: ILoadBalancerIngress[]; }; } -export interface ComputedIngressRoute { - displayAsLink: boolean; - pathname: string; - url: string; - service: string; -} - -export class Ingress extends KubeObject { - static kind = "Ingress"; - static namespaced = true; - static apiBase = "/apis/networking.k8s.io/v1/ingresses"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class Ingress extends KubeObject { + static readonly kind = "Ingress"; + static readonly namespaced = true; + static readonly apiBase = "/apis/networking.k8s.io/v1/ingresses"; getRules() { return this.spec.rules ?? []; @@ -150,12 +153,16 @@ export class Ingress extends KubeObject { return computeRouteDeclarations(this).map(({ url, service }) => `${url} ⇢ ${service}`); } - getServiceNamePort(): ExtensionsBackend { + getServiceNamePort(): ExtensionsBackend | undefined { const { spec: { backend, defaultBackend } = {}} = this; const serviceName = defaultBackend?.service?.name ?? backend?.serviceName; const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort; + if (!serviceName || !servicePort) { + return undefined; + } + return { serviceName, servicePort, @@ -170,14 +177,14 @@ export class Ingress extends KubeObject { getPorts() { const ports: number[] = []; - const { spec: { tls, rules, backend, defaultBackend }} = this; + const { spec: { tls, rules = [], backend, defaultBackend }} = this; const httpPort = 80; const tlsPort = 443; // Note: not using the port name (string) const servicePort = defaultBackend?.service?.port.number ?? backend?.servicePort; - if (rules && rules.length > 0) { - if (rules.some(rule => Object.prototype.hasOwnProperty.call(rule, "http"))) { + if (rules.length > 0) { + if (rules.some(rule => rule.http)) { ports.push(httpPort); } } else if (servicePort !== undefined) { @@ -192,14 +199,19 @@ export class Ingress extends KubeObject { } getLoadBalancers() { - const { status: { loadBalancer = { ingress: [] }}} = this; - - return (loadBalancer.ingress ?? []).map(address => ( + return this.status?.loadBalancer.ingress.map(address => ( address.hostname || address.ip - )); + )) ?? []; } } +export interface ComputedIngressRoute { + displayAsLink: boolean; + pathname: string; + url: string; + service: string; +} + export function computeRuleDeclarations(ingress: Ingress, rule: IngressRule): ComputedIngressRoute[] { const { host = "*", http: { paths } = { paths: [] }} = rule; const protocol = (ingress.spec?.tls?.length ?? 0) === 0 @@ -217,18 +229,3 @@ export function computeRuleDeclarations(ingress: Ingress, rule: IngressRule): Co export function computeRouteDeclarations(ingress: Ingress): ComputedIngressRoute[] { return ingress.getRules().flatMap(rule => computeRuleDeclarations(ingress, rule)); } - -let ingressApi: IngressApi; - -if (isClusterPageContext()) { - ingressApi = new IngressApi({ - objectConstructor: Ingress, - // Add fallback for Kubernetes <1.19 - checkPreferredVersion: true, - fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], - }); -} - -export { - ingressApi, -}; diff --git a/src/common/k8s-api/endpoints/job.api.injectable.ts b/src/common/k8s-api/endpoints/job.api.injectable.ts new file mode 100644 index 0000000000..c0bbe9161e --- /dev/null +++ b/src/common/k8s-api/endpoints/job.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { JobApi } from "./job.api"; + +const jobApiInjectable = getInjectable({ + id: "job-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "jobApi is only available in certain environments"); + + return new JobApi(); + }, +}); + +export default jobApiInjectable; diff --git a/src/common/k8s-api/endpoints/job.api.ts b/src/common/k8s-api/endpoints/job.api.ts index d59d49f921..252fc13f6e 100644 --- a/src/common/k8s-api/endpoints/job.api.ts +++ b/src/common/k8s-api/endpoints/job.api.ts @@ -3,88 +3,76 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; -import { autoBind } from "../../utils"; -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import type { IPodContainer, IPodMetrics } from "./pods.api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { PodContainer, PodMetricData, PodSpec } from "./pod.api"; +import type { KubeObjectScope, KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; -export class Job extends WorkloadKubeObject { - static kind = "Job"; - static namespaced = true; - static apiBase = "/apis/batch/v1/jobs"; +export interface JobSpec { + parallelism?: number; + completions?: number; + backoffLimit?: number; + selector?: LabelSelector; + template: { + metadata: { + creationTimestamp?: string; + labels?: Partial>; + annotations?: Partial>; + }; + spec: PodSpec; + }; + containers?: PodContainer[]; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + dnsPolicy?: string; + serviceAccountName?: string; + serviceAccount?: string; + schedulerName?: string; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); +export interface JobStatus extends KubeObjectStatus { + startTime: string; + completionTime: string; + succeeded: number; +} + +export class Job extends KubeObject { + static readonly kind = "Job"; + static readonly namespaced = true; + static readonly apiBase = "/apis/batch/v1/jobs"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector?.matchLabels); } - declare spec: { - parallelism?: number; - completions?: number; - backoffLimit?: number; - selector?: LabelSelector; - template: { - metadata: { - creationTimestamp?: string; - labels?: { - [name: string]: string; - }; - annotations?: { - [name: string]: string; - }; - }; - spec: { - containers: IPodContainer[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - hostPID: boolean; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - tolerations?: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - schedulerName: string; - }; - }; - containers?: IPodContainer[]; - restartPolicy?: string; - terminationGracePeriodSeconds?: number; - dnsPolicy?: string; - serviceAccountName?: string; - serviceAccount?: string; - schedulerName?: string; - }; - declare status: { - conditions: { - type: string; - status: string; - lastProbeTime: string; - lastTransitionTime: string; - message?: string; - }[]; - startTime: string; - completionTime: string; - succeeded: number; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata.labels); + } + + getTolerations() { + return this.spec.template.spec.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getDesiredCompletions() { - return this.spec.completions || 0; + return this.spec.completions ?? 0; } getCompletions() { - return this.status.succeeded || 0; + return this.status?.succeeded ?? 0; } getParallelism() { @@ -93,21 +81,25 @@ export class Job extends WorkloadKubeObject { getCondition() { // Type of Job condition could be only Complete or Failed - // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch - return this.status.conditions?.find(({ status }) => status === "True"); + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#jobcondition-v1-batch + return this.status?.conditions?.find(({ status }) => status === "True"); } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); - - return [...containers].map(container => container.image); + return this.spec.template.spec.containers?.map(container => container.image) ?? []; } } export class JobApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Job, + }); + } } -export function getMetricsForJobs(jobs: Job[], namespace: string, selector = ""): Promise { +export function getMetricsForJobs(jobs: Job[], namespace: string, selector = ""): Promise { const podSelector = jobs.map(job => `${job.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -123,15 +115,3 @@ export function getMetricsForJobs(jobs: Job[], namespace: string, selector = "") namespace, }); } - -let jobApi: JobApi; - -if (isClusterPageContext()) { - jobApi = new JobApi({ - objectConstructor: Job, - }); -} - -export { - jobApi, -}; diff --git a/src/common/k8s-api/endpoints/legacy-globals.ts b/src/common/k8s-api/endpoints/legacy-globals.ts new file mode 100644 index 0000000000..b4355e720a --- /dev/null +++ b/src/common/k8s-api/endpoints/legacy-globals.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import configMapApiInjectable from "./config-map.api.injectable"; +import cronJobApiInjectable from "./cron-job.api.injectable"; +import jobApiInjectable from "./job.api.injectable"; +import networkPolicyApiInjectable from "./network-policy.api.injectable"; +import nodeApiInjectable from "./node.api.injectable"; +import persistentVolumeClaimApiInjectable from "./persistent-volume-claim.api.injectable"; +import podApiInjectable from "./pod.api.injectable"; +import resourceQuotaApiInjectable from "./resource-quota.api.injectable"; +import roleApiInjectable from "./role.api.injectable"; +import secretApiInjectable from "./secret.api.injectable"; +import serviceApiInjectable from "./service.api.injectable"; +import storageClassApiInjectable from "./storage-class.api.injectable"; + +/** + * @deprecated use `di.inject(roleApiInjectable)` instead + */ +export const roleApi = asLegacyGlobalForExtensionApi(roleApiInjectable); + +/** + * @deprecated use `di.inject(podApiInjectable)` instead + */ +export const podApi = asLegacyGlobalForExtensionApi(podApiInjectable); + +/** + * @deprecated use `di.inject(cronJobApiInjectable)` instead + */ +export const cronJobApi = asLegacyGlobalForExtensionApi(cronJobApiInjectable); + +/** + * @deprecated use `di.inject(jobApiInjectable)` instead + */ +export const jobApi = asLegacyGlobalForExtensionApi(jobApiInjectable); + +/** + * @deprecated use `di.inject(configMapApiInjectable)` instead + */ +export const configMapApi = asLegacyGlobalForExtensionApi(configMapApiInjectable); + +/** + * @deprecated use `di.inject(networkPolicyApiInjectable)` instead + */ +export const networkPolicyApi = asLegacyGlobalForExtensionApi(networkPolicyApiInjectable); + +/** + * @deprecated use `di.inject(nodeApiInjectable)` instead + */ +export const nodeApi = asLegacyGlobalForExtensionApi(nodeApiInjectable); + +/** + * @deprecated use `di.inject(persistentVolumeClaimApiInjectable)` instead + */ +export const persistentVolumeClaimApi = asLegacyGlobalForExtensionApi(persistentVolumeClaimApiInjectable); + +/** + * @deprecated use `di.inject(resourceQuotaApiInjectable)` instead + */ +export const resourceQuotaApi = asLegacyGlobalForExtensionApi(resourceQuotaApiInjectable); + +/** + * @deprecated use `di.inject(secretApiInjectable)` instead + */ +export const secretApi = asLegacyGlobalForExtensionApi(secretApiInjectable); + +/** + * @deprecated use `di.inject(serviceApiInjectable)` instead + */ +export const serviceApi = asLegacyGlobalForExtensionApi(serviceApiInjectable); + +/** + * @deprecated use `di.inject(storageClassApiInjectable)` instead + */ +export const storageClassApi = asLegacyGlobalForExtensionApi(storageClassApiInjectable); diff --git a/src/common/k8s-api/endpoints/limit-range.api.injectable.ts b/src/common/k8s-api/endpoints/limit-range.api.injectable.ts new file mode 100644 index 0000000000..1842fdf33e --- /dev/null +++ b/src/common/k8s-api/endpoints/limit-range.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { LimitRangeApi } from "./limit-range.api"; + +const limitRangeApiInjectable = getInjectable({ + id: "limit-range-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "limitRangeApi is only available in certain environments"); + + return new LimitRangeApi(); + }, +}); + +export default limitRangeApiInjectable; diff --git a/src/common/k8s-api/endpoints/limit-range.api.ts b/src/common/k8s-api/endpoints/limit-range.api.ts index 07840e7dfc..704ae85b48 100644 --- a/src/common/k8s-api/endpoints/limit-range.api.ts +++ b/src/common/k8s-api/endpoints/limit-range.api.ts @@ -3,11 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { autoBind } from "../../utils"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export enum LimitType { CONTAINER = "Container", @@ -36,21 +35,14 @@ export interface LimitRangeItem extends LimitRangeParts { type: string; } -export interface LimitRange { - spec: { - limits: LimitRangeItem[]; - }; +export interface LimitRangeSpec { + limits: LimitRangeItem[]; } -export class LimitRange extends KubeObject { - static kind = "LimitRange"; - static namespaced = true; - static apiBase = "/api/v1/limitranges"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class LimitRange extends KubeObject { + static readonly kind = "LimitRange"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/limitranges"; getContainerLimits() { return this.spec.limits.filter(limit => limit.type === LimitType.CONTAINER); @@ -65,14 +57,11 @@ export class LimitRange extends KubeObject { } } -let limitRangeApi: KubeApi; - -if (isClusterPageContext()) { - limitRangeApi = new KubeApi({ - objectConstructor: LimitRange, - }); +export class LimitRangeApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: LimitRange, + ...opts ?? {}, + }); + } } - -export { - limitRangeApi, -}; diff --git a/src/common/k8s-api/endpoints/metrics.api.ts b/src/common/k8s-api/endpoints/metrics.api.ts index ad4044c5d3..7b0028b061 100644 --- a/src/common/k8s-api/endpoints/metrics.api.ts +++ b/src/common/k8s-api/endpoints/metrics.api.ts @@ -9,18 +9,18 @@ import moment from "moment"; import { apiBase } from "../index"; import type { IMetricsQuery } from "../../../main/routes/metrics/metrics-query"; -export interface IMetrics { +export interface MetricData { status: string; data: { resultType: string; - result: IMetricsResult[]; + result: MetricResult[]; }; } -export interface IMetricsResult { +export interface MetricResult { metric: { - [name: string]: string; - instance: string; + [name: string]: string | undefined; + instance?: string; node?: string; pod?: string; kubernetes?: string; @@ -44,7 +44,7 @@ export interface IMetricsReqParams { namespace?: string; // rbac-proxy validation param } -export interface IResourceMetrics { +export interface IResourceMetrics { [metric: string]: T; cpuUsage: T; memoryUsage: T; @@ -56,7 +56,7 @@ export interface IResourceMetrics { } export const metricsApi = { - async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { + async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { const { range = 3600, step = 60, namespace } = reqParams; let { start, end } = reqParams; @@ -82,7 +82,7 @@ export const metricsApi = { }, }; -export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { +export function normalizeMetrics(metrics: MetricData | undefined | null, frames = 60): MetricData { if (!metrics?.data?.result) { return { data: { @@ -90,7 +90,7 @@ export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { result: [{ metric: {}, values: [], - } as IMetricsResult], + }], }, status: "", }; @@ -131,17 +131,17 @@ export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { result.push({ metric: {}, values: [], - } as IMetricsResult); + } as MetricResult); } return metrics; } -export function isMetricsEmpty(metrics: Record) { +export function isMetricsEmpty(metrics: Partial>) { return Object.values(metrics).every(metric => !metric?.data?.result?.length); } -export function getItemMetrics(metrics: Record, itemName: string): Record | undefined { +export function getItemMetrics(metrics: Partial> | null | undefined, itemName: string): Partial> | undefined { if (!metrics) { return undefined; } @@ -152,23 +152,24 @@ export function getItemMetrics(metrics: Record, itemName: stri if (!metrics[metric]?.data?.result) { continue; } - const results = metrics[metric].data.result; - const result = results.find(res => Object.values(res.metric)[0] == itemName); + const results = metrics[metric]?.data.result; + const result = results?.find(res => Object.values(res.metric)[0] == itemName); - itemMetrics[metric].data.result = result ? [result] : []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemMetrics[metric]!.data.result = result ? [result] : []; } return itemMetrics; } -export function getMetricLastPoints(metrics: Record) { +export function getMetricLastPoints>>(metrics: T): Record { const result: Partial<{ [metric: string]: number }> = {}; Object.keys(metrics).forEach(metricName => { try { const metric = metrics[metricName]; - if (metric.data.result.length) { + if (metric?.data.result.length) { result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; } } catch { @@ -178,5 +179,5 @@ export function getMetricLastPoints(metrics: Record) { return result; }, {}); - return result; + return result as Record; } diff --git a/src/common/k8s-api/endpoints/namespace.api.injectable.ts b/src/common/k8s-api/endpoints/namespace.api.injectable.ts new file mode 100644 index 0000000000..0ff259f58d --- /dev/null +++ b/src/common/k8s-api/endpoints/namespace.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { NamespaceApi } from "./namespace.api"; + +const namespaceApiInjectable = getInjectable({ + id: "namespace-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "namespaceApi is only available in certain environments"); + + return new NamespaceApi(); + }, +}); + +export default namespaceApiInjectable; diff --git a/src/common/k8s-api/endpoints/namespaces.api.ts b/src/common/k8s-api/endpoints/namespace.api.ts similarity index 51% rename from src/common/k8s-api/endpoints/namespaces.api.ts rename to src/common/k8s-api/endpoints/namespace.api.ts index 07b51f701d..b95245f712 100644 --- a/src/common/k8s-api/endpoints/namespaces.api.ts +++ b/src/common/k8s-api/endpoints/namespace.api.ts @@ -3,34 +3,30 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { KubeObjectScope, KubeObjectStatus } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind } from "../../../renderer/utils"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { PodMetricData } from "./pod.api"; -export enum NamespaceStatus { +export enum NamespaceStatusKind { ACTIVE = "Active", TERMINATING = "Terminating", } -export interface Namespace { - status?: { - phase: string; - }; +export interface NamespaceSpec { + finalizers?: string[]; } -export class Namespace extends KubeObject { - static kind = "Namespace"; - static namespaced = false; - static apiBase = "/api/v1/namespaces"; +export interface NamespaceStatus extends KubeObjectStatus { + phase?: string; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class Namespace extends KubeObject { + static readonly kind = "Namespace"; + static readonly namespaced = false; + static readonly apiBase = "/api/v1/namespaces"; getStatus() { return this.status?.phase ?? "-"; @@ -38,9 +34,15 @@ export class Namespace extends KubeObject { } export class NamespaceApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Namespace, + }); + } } -export function getMetricsForNamespace(namespace: string, selector = ""): Promise { +export function getMetricsForNamespace(namespace: string, selector = ""): Promise { const opts = { category: "pods", pods: ".*", namespace, selector }; return metricsApi.getMetrics({ @@ -55,15 +57,3 @@ export function getMetricsForNamespace(namespace: string, selector = ""): Promis namespace, }); } - -let namespacesApi: NamespaceApi; - -if (isClusterPageContext()) { - namespacesApi = new NamespaceApi({ - objectConstructor: Namespace, - }); -} - -export { - namespacesApi, -}; diff --git a/src/common/k8s-api/endpoints/network-policy.api.injectable.ts b/src/common/k8s-api/endpoints/network-policy.api.injectable.ts new file mode 100644 index 0000000000..e351451f53 --- /dev/null +++ b/src/common/k8s-api/endpoints/network-policy.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { NetworkPolicyApi } from "./network-policy.api"; + +const networkPolicyApiInjectable = getInjectable({ + id: "network-policy-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "networkPolicyApi is only available in certain environments"); + + return new NetworkPolicyApi(); + }, +}); + +export default networkPolicyApiInjectable; diff --git a/src/common/k8s-api/endpoints/network-policy.api.ts b/src/common/k8s-api/endpoints/network-policy.api.ts index 64bdc563b0..0a369cfb94 100644 --- a/src/common/k8s-api/endpoints/network-policy.api.ts +++ b/src/common/k8s-api/endpoints/network-policy.api.ts @@ -3,12 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LabelSelector } from "../kube-object"; +import type { KubeObjectScope, LabelSelector } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface IPolicyIpBlock { cidr: string; @@ -105,15 +103,10 @@ export interface NetworkPolicy { spec: NetworkPolicySpec; } -export class NetworkPolicy extends KubeObject { - static kind = "NetworkPolicy"; - static namespaced = true; - static apiBase = "/apis/networking.k8s.io/v1/networkpolicies"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class NetworkPolicy extends KubeObject { + static readonly kind = "NetworkPolicy"; + static readonly namespaced = true; + static readonly apiBase = "/apis/networking.k8s.io/v1/networkpolicies"; getMatchLabels(): string[] { if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return []; @@ -130,14 +123,11 @@ export class NetworkPolicy extends KubeObject { } } -let networkPolicyApi: KubeApi; - -if (isClusterPageContext()) { - networkPolicyApi = new KubeApi({ - objectConstructor: NetworkPolicy, - }); +export class NetworkPolicyApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: NetworkPolicy, + ...opts, + }); + } } - -export { - networkPolicyApi, -}; diff --git a/src/common/k8s-api/endpoints/node.api.injectable.ts b/src/common/k8s-api/endpoints/node.api.injectable.ts new file mode 100644 index 0000000000..d382e9a7ba --- /dev/null +++ b/src/common/k8s-api/endpoints/node.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { NodeApi } from "./node.api"; + +const nodeApiInjectable = getInjectable({ + id: "node-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "nodeApi is only available in certain environments"); + + return new NodeApi(); + }, +}); + +export default nodeApiInjectable; diff --git a/src/common/k8s-api/endpoints/node.api.ts b/src/common/k8s-api/endpoints/node.api.ts new file mode 100644 index 0000000000..a51db59fa7 --- /dev/null +++ b/src/common/k8s-api/endpoints/node.api.ts @@ -0,0 +1,287 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { BaseKubeObjectCondition, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { cpuUnitsToNumber, unitsToBytes, isObject } from "../../../renderer/utils"; +import type { MetricData } from "./metrics.api"; +import { metricsApi } from "./metrics.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { TypedRegEx } from "typed-regex"; + +export class NodeApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Node, + }); + } +} + +export function getMetricsForAllNodes(): Promise { + const opts = { category: "nodes" }; + + return metricsApi.getMetrics({ + memoryUsage: opts, + workloadMemoryUsage: opts, + memoryCapacity: opts, + memoryAllocatableCapacity: opts, + cpuUsage: opts, + cpuCapacity: opts, + fsSize: opts, + fsUsage: opts, + }); +} + +export interface NodeMetricData extends Partial> { + memoryUsage: MetricData; + workloadMemoryUsage: MetricData; + memoryCapacity: MetricData; + memoryAllocatableCapacity: MetricData; + cpuUsage: MetricData; + cpuCapacity: MetricData; + fsUsage: MetricData; + fsSize: MetricData; +} + +export interface NodeTaint { + key: string; + value?: string; + effect: string; + timeAdded: string; +} + +export function formatNodeTaint(taint: NodeTaint): string { + if (taint.value) { + return `${taint.key}=${taint.value}:${taint.effect}`; + } + + return `${taint.key}:${taint.effect}`; +} + +export interface NodeCondition extends BaseKubeObjectCondition { + /** + * Last time we got an update on a given condition. + */ + lastHeartbeatTime?: string; +} + +/** + * These role label prefixs are the ones that are for master nodes + * + * The `master` label has been deprecated in Kubernetes 1.20, and will be removed in 1.25 so we + * have to also use the newer `control-plane` label + */ +const masterNodeLabels = [ + "master", + "control-plane", +]; + +/** + * This regex is used in the `getRoleLabels()` method bellow, but placed here + * as factoring out regexes is best practice. + */ +const nodeRoleLabelKeyMatcher = TypedRegEx("^.*node-role.kubernetes.io/+(?.+)$"); + +export interface NodeSpec { + podCIDR?: string; + podCIDRs?: string[]; + providerID?: string; + /** + * @deprecated see https://issues.k8s.io/61966 + */ + externalID?: string; + taints?: NodeTaint[]; + unschedulable?: boolean; +} + +export interface NodeAddress { + type: "Hostname" | "ExternalIP" | "InternalIP"; + address: string; +} + +export interface NodeStatusResources extends Partial> { + cpu?: string; + "ephemeral-storage"?: string; + "hugepages-1Gi"?: string; + "hugepages-2Mi"?: string; + memory?: string; + pods?: string; +} + +export interface ConfigMapNodeConfigSource { + kubeletConfigKey: string; + name: string; + namespace: string; + resourceVersion?: string; + uid?: string; +} + +export interface NodeConfigSource { + configMap?: ConfigMapNodeConfigSource; +} + +export interface NodeConfigStatus { + active?: NodeConfigSource; + assigned?: NodeConfigSource; + lastKnownGood?: NodeConfigSource; + error?: string; +} + +export interface DaemonEndpoint { + Port: number; //it must be uppercase for backwards compatibility +} + +export interface NodeDaemonEndpoints { + kubeletEndpoint?: DaemonEndpoint; +} + +export interface ContainerImage { + names?: string[]; + sizeBytes?: number; +} + +export interface NodeSystemInfo { + architecture: string; + bootID: string; + containerRuntimeVersion: string; + kernelVersion: string; + kubeProxyVersion: string; + kubeletVersion: string; + machineID: string; + operatingSystem: string; + osImage: string; + systemUUID: string; +} + +export interface AttachedVolume { + name: string; + devicePath: string; +} + +export interface NodeStatus { + capacity?: NodeStatusResources; + allocatable?: NodeStatusResources; + conditions?: NodeCondition[]; + addresses?: NodeAddress[]; + config?: NodeConfigStatus; + daemonEndpoints?: NodeDaemonEndpoints; + images?: ContainerImage[]; + nodeInfo?: NodeSystemInfo; + phase?: string; + volumesInUse?: string[]; + volumesAttached?: AttachedVolume[]; +} + +export class Node extends KubeObject { + static readonly kind = "Node"; + static readonly namespaced = false; + static readonly apiBase = "/api/v1/nodes"; + + /** + * Returns the concatination of all current condition types which have a status + * of `"True"` + */ + getNodeConditionText(): string { + if (!this.status?.conditions) { + return ""; + } + + return this.status.conditions + .filter(condition => condition.status === "True") + .map(condition => condition.type) + .join(" "); + } + + getTaints() { + return this.spec.taints || []; + } + + isMasterNode(): boolean { + return this.getRoleLabelItems() + .some(roleLabel => masterNodeLabels.includes(roleLabel)); + } + + getRoleLabelItems(): string[] { + const { labels } = this.metadata; + const roleLabels: string[] = []; + + if (!isObject(labels)) { + return roleLabels; + } + + for (const labelKey of Object.keys(labels)) { + const match = nodeRoleLabelKeyMatcher.match(labelKey); + + if (match?.groups) { + roleLabels.push(match.groups.role); + } + } + + if (typeof labels["kubernetes.io/role"] === "string") { + roleLabels.push(labels["kubernetes.io/role"]); + } + + if (typeof labels["node.kubernetes.io/role"] === "string") { + roleLabels.push(labels["node.kubernetes.io/role"]); + } + + return roleLabels; + } + + getRoleLabels(): string { + return this.getRoleLabelItems().join(", "); + } + + getCpuCapacity() { + if (!this.status?.capacity || !this.status.capacity.cpu) return 0; + + return cpuUnitsToNumber(this.status.capacity.cpu); + } + + getMemoryCapacity() { + if (!this.status?.capacity || !this.status.capacity.memory) return 0; + + return unitsToBytes(this.status.capacity.memory); + } + + getConditions(): NodeCondition[] { + const conditions = this.status?.conditions || []; + + if (this.isUnschedulable()) { + return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; + } + + return conditions; + } + + getActiveConditions() { + return this.getConditions().filter(c => c.status === "True"); + } + + getWarningConditions() { + const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"]; + + return this.getActiveConditions().filter(condition => { + return !goodConditions.includes(condition.type); + }); + } + + getKubeletVersion() { + return this.status?.nodeInfo?.kubeletVersion ?? ""; + } + + getOperatingSystem(): string { + return this.metadata?.labels?.["kubernetes.io/os"] + || this.metadata?.labels?.["beta.kubernetes.io/os"] + || this.status?.nodeInfo?.operatingSystem + || "linux"; + } + + isUnschedulable() { + return this.spec.unschedulable; + } +} diff --git a/src/common/k8s-api/endpoints/nodes.api.ts b/src/common/k8s-api/endpoints/nodes.api.ts deleted file mode 100644 index 62edc090e4..0000000000 --- a/src/common/k8s-api/endpoints/nodes.api.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObject } from "../kube-object"; -import { autoBind, cpuUnitsToNumber, iter, unitsToBytes } from "../../../renderer/utils"; -import type { IMetrics } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; -import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class NodesApi extends KubeApi { -} - -export function getMetricsForAllNodes(): Promise { - const opts = { category: "nodes" }; - - return metricsApi.getMetrics({ - memoryUsage: opts, - workloadMemoryUsage: opts, - memoryCapacity: opts, - memoryAllocatableCapacity: opts, - cpuUsage: opts, - cpuCapacity: opts, - fsSize: opts, - fsUsage: opts, - }); -} - -export interface INodeMetrics { - [metric: string]: T; - memoryUsage: T; - workloadMemoryUsage: T; - memoryCapacity: T; - memoryAllocatableCapacity: T; - cpuUsage: T; - cpuCapacity: T; - fsUsage: T; - fsSize: T; -} - -export interface NodeTaint { - key: string; - value?: string; - effect: string; - timeAdded: string; -} - -export function formatNodeTaint(taint: NodeTaint): string { - if (taint.value) { - return `${taint.key}=${taint.value}:${taint.effect}`; - } - - return `${taint.key}:${taint.effect}`; -} - -export interface NodeCondition { - type: string; - status: string; - lastHeartbeatTime?: string; - lastTransitionTime?: string; - reason?: string; - message?: string; -} - -export interface Node { - spec: { - podCIDR?: string; - podCIDRs?: string[]; - providerID?: string; - /** - * @deprecated see https://issues.k8s.io/61966 - */ - externalID?: string; - taints?: NodeTaint[]; - unschedulable?: boolean; - }; - status: { - capacity?: { - cpu: string; - "ephemeral-storage": string; - "hugepages-1Gi": string; - "hugepages-2Mi": string; - memory: string; - pods: string; - }; - allocatable?: { - cpu: string; - "ephemeral-storage": string; - "hugepages-1Gi": string; - "hugepages-2Mi": string; - memory: string; - pods: string; - }; - conditions?: NodeCondition[]; - addresses?: { - type: string; - address: string; - }[]; - daemonEndpoints?: { - kubeletEndpoint: { - Port: number; //it must be uppercase for backwards compatibility - }; - }; - nodeInfo?: { - machineID: string; - systemUUID: string; - bootID: string; - kernelVersion: string; - osImage: string; - containerRuntimeVersion: string; - kubeletVersion: string; - kubeProxyVersion: string; - operatingSystem: string; - architecture: string; - }; - images?: { - names: string[]; - sizeBytes?: number; - }[]; - volumesInUse?: string[]; - volumesAttached?: { - name: string; - devicePath: string; - }[]; - }; -} - -/** - * Iterate over `conditions` yielding the `type` field if the `status` field is - * the string `"True"` - * @param conditions An iterator of some conditions - */ -function* getTrueConditionTypes(conditions: IterableIterator | Iterable): IterableIterator { - for (const { status, type } of conditions) { - if (status === "True") { - yield type; - } - } -} - -/** - * This regex is used in the `getRoleLabels()` method bellow, but placed here - * as factoring out regexes is best practice. - */ -const nodeRoleLabelKeyMatcher = /^.*node-role.kubernetes.io\/+(?.+)$/; - -export class Node extends KubeObject { - static kind = "Node"; - static namespaced = false; - static apiBase = "/api/v1/nodes"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - - /** - * Returns the concatination of all current condition types which have a status - * of `"True"` - */ - getNodeConditionText(): string { - return iter.join( - getTrueConditionTypes(this.status?.conditions ?? []), - " ", - ); - } - - getTaints() { - return this.spec.taints || []; - } - - getRoleLabels(): string { - const { labels } = this.metadata; - - if (!labels || typeof labels !== "object") { - return ""; - } - - const roleLabels: string[] = []; - - for (const labelKey of Object.keys(labels)) { - const match = nodeRoleLabelKeyMatcher.exec(labelKey); - - if (match) { - roleLabels.push(match.groups.role); - } - } - - if (typeof labels["kubernetes.io/role"] === "string") { - roleLabels.push(labels["kubernetes.io/role"]); - } - - if (typeof labels["node.kubernetes.io/role"] === "string") { - roleLabels.push(labels["node.kubernetes.io/role"]); - } - - return roleLabels.join(", "); - } - - getCpuCapacity() { - if (!this.status.capacity || !this.status.capacity.cpu) return 0; - - return cpuUnitsToNumber(this.status.capacity.cpu); - } - - getMemoryCapacity() { - if (!this.status.capacity || !this.status.capacity.memory) return 0; - - return unitsToBytes(this.status.capacity.memory); - } - - getConditions() { - const conditions = this.status.conditions || []; - - if (this.isUnschedulable()) { - return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; - } - - return conditions; - } - - getActiveConditions() { - return this.getConditions().filter(c => c.status === "True"); - } - - getWarningConditions() { - const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"]; - - return this.getActiveConditions().filter(condition => { - return !goodConditions.includes(condition.type); - }); - } - - getKubeletVersion() { - return this.status.nodeInfo.kubeletVersion; - } - - getOperatingSystem(): string { - return this.metadata?.labels?.["kubernetes.io/os"] - || this.metadata?.labels?.["beta.kubernetes.io/os"] - || this.status?.nodeInfo?.operatingSystem - || "linux"; - } - - isUnschedulable() { - return this.spec.unschedulable; - } -} - -let nodesApi: NodesApi; - -if (isClusterPageContext()) { - nodesApi = new NodesApi({ - objectConstructor: Node, - }); -} - -export { - nodesApi, -}; diff --git a/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts b/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts new file mode 100644 index 0000000000..fcd539b429 --- /dev/null +++ b/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PersistentVolumeClaimApi } from "./persistent-volume-claim.api"; + +const persistentVolumeClaimApiInjectable = getInjectable({ + id: "persistent-volume-claim-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "persistentVolumeClaimApi is only available in certain environments"); + + return new PersistentVolumeClaimApi(); + }, +}); + +export default persistentVolumeClaimApiInjectable; diff --git a/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts b/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts new file mode 100644 index 0000000000..947aeb4e57 --- /dev/null +++ b/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, LabelSelector, TypedLocalObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { MetricData } from "./metrics.api"; +import { metricsApi } from "./metrics.api"; +import type { Pod } from "./pod.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { object } from "../../utils"; +import type { ResourceRequirements } from "./types/resource-requirements"; + +export class PersistentVolumeClaimApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PersistentVolumeClaim, + }); + } +} + +export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { + const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() }; + + return metricsApi.getMetrics({ + diskUsage: opts, + diskCapacity: opts, + }, { + namespace: opts.namespace, + }); +} + +export interface PersistentVolumeClaimMetricData extends Partial> { + diskUsage: MetricData; + diskCapacity: MetricData; +} + +export interface PersistentVolumeClaimSpec { + accessModes?: string[]; + dataSource?: TypedLocalObjectReference; + dataSourceRef?: TypedLocalObjectReference; + resources?: ResourceRequirements; + selector?: LabelSelector; + storageClassName?: string; + volumeMode?: string; + volumeName?: string; +} + +export interface PersistentVolumeClaimStatus { + phase: string; // Pending +} + +export class PersistentVolumeClaim extends KubeObject { + static readonly kind = "PersistentVolumeClaim"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/persistentvolumeclaims"; + + getPods(pods: Pod[]): Pod[] { + return pods + .filter(pod => pod.getNs() === this.getNs()) + .filter(pod => ( + pod.getVolumes() + .filter(volume => volume.persistentVolumeClaim?.claimName === this.getName()) + .length > 0 + )); + } + + getStorage(): string { + return this.spec.resources?.requests?.storage ?? "-"; + } + + getMatchLabels(): string[] { + return object.entries(this.spec.selector?.matchLabels) + .map(([name, val]) => `${name}:${val}`); + } + + getMatchExpressions() { + return this.spec.selector?.matchExpressions ?? []; + } + + getStatus(): string { + return this.status?.phase ?? "-"; + } +} diff --git a/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts b/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts deleted file mode 100644 index d013845d3c..0000000000 --- a/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { LabelSelector } from "../kube-object"; -import { KubeObject } from "../kube-object"; -import { autoBind } from "../../utils"; -import type { IMetrics } from "./metrics.api"; -import { metricsApi } from "./metrics.api"; -import type { Pod } from "./pods.api"; -import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class PersistentVolumeClaimsApi extends KubeApi { -} - -export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { - const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() }; - - return metricsApi.getMetrics({ - diskUsage: opts, - diskCapacity: opts, - }, { - namespace: opts.namespace, - }); -} - -export interface IPvcMetrics { - [key: string]: T; - diskUsage: T; - diskCapacity: T; -} - -export interface PersistentVolumeClaimSpec { - accessModes: string[]; - selector: LabelSelector; - resources: { - requests?: Record; - limits?: Record; - }; - volumeName?: string; - storageClassName?: string; - volumeMode?: string; - dataSource?: { - apiGroup: string; - kind: string; - name: string; - }; -} - -export interface PersistentVolumeClaim { - spec: PersistentVolumeClaimSpec; - status: { - phase: string; // Pending - }; -} - -export class PersistentVolumeClaim extends KubeObject { - static kind = "PersistentVolumeClaim"; - static namespaced = true; - static apiBase = "/api/v1/persistentvolumeclaims"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - - getPods(allPods: Pod[]): Pod[] { - const pods = allPods.filter(pod => pod.getNs() === this.getNs()); - - return pods.filter(pod => { - return pod.getVolumes().filter(volume => - volume.persistentVolumeClaim && - volume.persistentVolumeClaim.claimName === this.getName(), - ).length > 0; - }); - } - - getStorage(): string { - if (!this.spec.resources || !this.spec.resources.requests) return "-"; - - return this.spec.resources.requests.storage; - } - - getMatchLabels(): string[] { - if (!this.spec.selector || !this.spec.selector.matchLabels) return []; - - return Object.entries(this.spec.selector.matchLabels) - .map(([name, val]) => `${name}:${val}`); - } - - getMatchExpressions() { - if (!this.spec.selector || !this.spec.selector.matchExpressions) return []; - - return this.spec.selector.matchExpressions; - } - - getStatus(): string { - if (this.status) return this.status.phase; - - return "-"; - } -} - -let pvcApi: PersistentVolumeClaimsApi; - -if (isClusterPageContext()) { - pvcApi = new PersistentVolumeClaimsApi({ - objectConstructor: PersistentVolumeClaim, - }); -} - -export { - pvcApi, -}; diff --git a/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts b/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts new file mode 100644 index 0000000000..7627b9d3c3 --- /dev/null +++ b/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PersistentVolumeApi } from "./persistent-volume.api"; + +const persistentVolumeApiInjectable = getInjectable({ + id: "persistent-volume-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "persistentVolumeApi is only available in certain environments"); + + return new PersistentVolumeApi(); + }, +}); + +export default persistentVolumeApiInjectable; diff --git a/src/common/k8s-api/endpoints/persistent-volume.api.ts b/src/common/k8s-api/endpoints/persistent-volume.api.ts index cf225dace8..71ec288f85 100644 --- a/src/common/k8s-api/endpoints/persistent-volume.api.ts +++ b/src/common/k8s-api/endpoints/persistent-volume.api.ts @@ -3,64 +3,76 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectScope, LabelSelector, ObjectReference, TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind, unitsToBytes } from "../../utils"; +import { unitsToBytes } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { ResourceRequirements } from "./types/resource-requirements"; -export interface PersistentVolume { - spec: { - capacity: { - storage: string; // 8Gi - }; - flexVolume: { - driver: string; // ceph.rook.io/rook-ceph-system, - options: { - clusterNamespace: string; // rook-ceph, - image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb, - pool: string; // replicapool, - storageClass: string; // rook-ceph-block - }; - }; - mountOptions?: string[]; - accessModes: string[]; // [ReadWriteOnce] - claimRef: { - kind: string; // PersistentVolumeClaim, - namespace: string; // storage, - name: string; // nfs-provisioner, - uid: string; // c5d7c485-9f1b-11e8-b0ea-9600000e54fb, - apiVersion: string; // v1, - resourceVersion: string; // 292180 - }; - persistentVolumeReclaimPolicy: string; // Delete, - storageClassName: string; // rook-ceph-block - nfs?: { - path: string; - server: string; +export interface PersistentVolumeSpec { + /** + * AccessModes contains the desired access modes the volume should have. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + */ + accessModes?: string[]; + dataSource?: TypedLocalObjectReference; + dataSourceRef?: TypedLocalObjectReference; + resources?: ResourceRequirements; + selector?: LabelSelector; + + /** + * Name of the StorageClass required by the claim. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + */ + storageClassName?: string; + + /** + * Defines what type of volume is required by the claim. Value of Filesystem is implied when not + * included in claim spec. + */ + volumeMode?: string; + + /** + * A description of the persistent volume\'s resources and capacity. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#capacity + */ + capacity?: Partial>; + flexVolume?: { + driver: string; // ceph.rook.io/rook-ceph-system, + options: { + clusterNamespace: string; // rook-ceph, + image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb, + pool: string; // replicapool, + storageClass: string; // rook-ceph-block }; }; - - status?: { - phase: string; - reason?: string; + mountOptions?: string[]; + claimRef?: ObjectReference; + persistentVolumeReclaimPolicy?: string; // Delete, + nfs?: { + path: string; + server: string; }; } -export class PersistentVolume extends KubeObject { +export interface PersistentVolumeStatus { + phase: string; + reason?: string; +} + +export class PersistentVolume extends KubeObject { static kind = "PersistentVolume"; static namespaced = false; static apiBase = "/api/v1/persistentvolumes"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - getCapacity(inBytes = false) { const capacity = this.spec.capacity; - if (capacity) { + if (capacity?.storage) { if (inBytes) return unitsToBytes(capacity.storage); return capacity.storage; @@ -74,7 +86,7 @@ export class PersistentVolume extends KubeObject { } getStorageClass(): string { - return this.spec.storageClassName; + return this.spec.storageClassName ?? ""; } getClaimRefName(): string { @@ -86,14 +98,11 @@ export class PersistentVolume extends KubeObject { } } -let persistentVolumeApi: KubeApi; - -if (isClusterPageContext()) { - persistentVolumeApi = new KubeApi({ - objectConstructor: PersistentVolume, - }); +export class PersistentVolumeApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PersistentVolume, + }); + } } - -export { - persistentVolumeApi, -}; diff --git a/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts b/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts new file mode 100644 index 0000000000..afc8190eb3 --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodDisruptionBudgetApi } from "./pod-disruption-budget.api"; + +const podDisruptionBudgetApiInjectable = getInjectable({ + id: "pod-disruption-budget-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podDisruptionBudgetApi is only available in certain environments"); + + return new PodDisruptionBudgetApi(); + }, +}); + +export default podDisruptionBudgetApiInjectable; diff --git a/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts b/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts new file mode 100644 index 0000000000..9acd55c71a --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface PodDisruptionBudgetSpec { + minAvailable: string; + maxUnavailable: string; + selector: LabelSelector; +} + +export interface PodDisruptionBudgetStatus { + currentHealthy: number; + desiredHealthy: number; + disruptionsAllowed: number; + expectedPods: number; +} + +export class PodDisruptionBudget extends KubeObject { + static readonly kind = "PodDisruptionBudget"; + static readonly namespaced = true; + static readonly apiBase = "/apis/policy/v1beta1/poddisruptionbudgets"; + + getSelectors() { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + } + + getMinAvailable() { + return this.spec.minAvailable || "N/A"; + } + + getMaxUnavailable() { + return this.spec.maxUnavailable || "N/A"; + } + + getCurrentHealthy() { + return this.status?.currentHealthy ?? 0; + } + + getDesiredHealthy() { + return this.status?.desiredHealthy ?? 0; + } +} + +export class PodDisruptionBudgetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: PodDisruptionBudget, + ...opts, + }); + } +} diff --git a/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts b/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts new file mode 100644 index 0000000000..6d5d8efaf6 --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodMetricsApi } from "./pod-metrics.api"; + +const podMetricsApiInjectable = getInjectable({ + id: "pod-metrics-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podMetricsApi is only available in certain environments"); + + return new PodMetricsApi(); + }, +}); + +export default podMetricsApiInjectable; diff --git a/src/common/k8s-api/endpoints/pod-metrics.api.ts b/src/common/k8s-api/endpoints/pod-metrics.api.ts index f92e6a6403..cdbe99417b 100644 --- a/src/common/k8s-api/endpoints/pod-metrics.api.ts +++ b/src/common/k8s-api/endpoints/pod-metrics.api.ts @@ -3,36 +3,55 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; -export interface PodMetrics { +export interface PodMetricsData extends KubeJsonApiData, void, void> { timestamp: string; window: string; - containers: { - name: string; - usage: { - cpu: string; - memory: string; - }; - }[]; + containers: PodMetricsContainer[]; } -export class PodMetrics extends KubeObject { - static kind = "PodMetrics"; - static namespaced = true; - static apiBase = "/apis/metrics.k8s.io/v1beta1/pods"; +export interface PodMetricsContainerUsage { + cpu: string; + memory: string; } -let podMetricsApi: KubeApi; - -if (isClusterPageContext()) { - podMetricsApi = new KubeApi({ - objectConstructor: PodMetrics, - }); +export interface PodMetricsContainer { + name: string; + usage: PodMetricsContainerUsage; } -export { - podMetricsApi, -}; +export class PodMetrics extends KubeObject { + static readonly kind = "PodMetrics"; + static readonly namespaced = true; + static readonly apiBase = "/apis/metrics.k8s.io/v1beta1/pods"; + + timestamp: string; + window: string; + containers: PodMetricsContainer[]; + + constructor({ + timestamp, + window, + containers, + ...rest + }: PodMetricsData) { + super(rest); + this.timestamp = timestamp; + this.window = window; + this.containers = containers; + } +} + +export class PodMetricsApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PodMetrics, + }); + } +} diff --git a/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts b/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts new file mode 100644 index 0000000000..81b65660a9 --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodSecurityPolicyApi } from "./pod-security-policy.api"; + +const podSecurityPolicyApiInjectable = getInjectable({ + id: "pod-security-policy-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podSecurityPolicyApi is only available in certain environments"); + + return new PodSecurityPolicyApi(); + }, +}); + +export default podSecurityPolicyApiInjectable; diff --git a/src/common/k8s-api/endpoints/pod-security-policy.api.ts b/src/common/k8s-api/endpoints/pod-security-policy.api.ts new file mode 100644 index 0000000000..0430391a01 --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-security-policy.api.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface PodSecurityPolicySpec { + allowPrivilegeEscalation?: boolean; + allowedCSIDrivers?: { + name: string; + }[]; + allowedCapabilities: string[]; + allowedFlexVolumes?: { + driver: string; + }[]; + allowedHostPaths?: { + pathPrefix: string; + readOnly: boolean; + }[]; + allowedProcMountTypes?: string[]; + allowedUnsafeSysctls?: string[]; + defaultAddCapabilities?: string[]; + defaultAllowPrivilegeEscalation?: boolean; + forbiddenSysctls?: string[]; + fsGroup?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + hostIPC?: boolean; + hostNetwork?: boolean; + hostPID?: boolean; + hostPorts?: { + max: number; + min: number; + }[]; + privileged?: boolean; + readOnlyRootFilesystem?: boolean; + requiredDropCapabilities?: string[]; + runAsGroup?: { + ranges: { + max: number; + min: number; + }[]; + rule: string; + }; + runAsUser?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + runtimeClass?: { + allowedRuntimeClassNames: string[]; + defaultRuntimeClassName: string; + }; + seLinux?: { + rule: string; + seLinuxOptions: { + level: string; + role: string; + type: string; + user: string; + }; + }; + supplementalGroups?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + volumes?: string[]; +} + +export class PodSecurityPolicy extends KubeObject { + static readonly kind = "PodSecurityPolicy"; + static readonly namespaced = false; + static readonly apiBase = "/apis/policy/v1beta1/podsecuritypolicies"; + + isPrivileged() { + return !!this.spec.privileged; + } + + getVolumes() { + return this.spec.volumes || []; + } + + getRules() { + const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec; + + return { + fsGroup: fsGroup ? fsGroup.rule : "", + runAsGroup: runAsGroup ? runAsGroup.rule : "", + runAsUser: runAsUser ? runAsUser.rule : "", + supplementalGroups: supplementalGroups ? supplementalGroups.rule : "", + seLinux: seLinux ? seLinux.rule : "", + }; + } +} + +export class PodSecurityPolicyApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PodSecurityPolicy, + + }); + } +} diff --git a/src/common/k8s-api/endpoints/pod.api.injectable.ts b/src/common/k8s-api/endpoints/pod.api.injectable.ts new file mode 100644 index 0000000000..b92d213f77 --- /dev/null +++ b/src/common/k8s-api/endpoints/pod.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodApi } from "./pod.api"; + +const podApiInjectable = getInjectable({ + id: "pod-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podApi is only available in certain environments"); + + return new PodApi(); + }, +}); + +export default podApiInjectable; diff --git a/src/common/k8s-api/endpoints/pods.api.ts b/src/common/k8s-api/endpoints/pod.api.ts similarity index 68% rename from src/common/k8s-api/endpoints/pods.api.ts rename to src/common/k8s-api/endpoints/pod.api.ts index c76a3d021c..fbacf4fb07 100644 --- a/src/common/k8s-api/endpoints/pods.api.ts +++ b/src/common/k8s-api/endpoints/pod.api.ts @@ -3,28 +3,33 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; -import type { IMetrics } from "./metrics.api"; +import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions, ResourceDescriptor } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { RequireExactlyOne } from "type-fest"; -import type { KubeObjectMetadata, LocalObjectReference } from "../kube-object"; +import type { KubeObjectMetadata, LocalObjectReference, Affinity, Toleration, LabelSelector, KubeObjectScope } from "../kube-object"; import type { SecretReference } from "./secret.api"; -import type { PersistentVolumeClaimSpec } from "./persistent-volume-claims.api"; +import type { PersistentVolumeClaimSpec } from "./persistent-volume-claim.api"; +import { KubeObject } from "../kube-object"; +import { isDefined } from "../../utils"; -export class PodsApi extends KubeApi { - getLogs = async (params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise => { +export class PodApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Pod, + }); + } + + async getLogs(params: ResourceDescriptor, query?: PodLogsQuery): Promise { const path = `${this.getUrl(params)}/log`; return this.request.get(path, { query }); - }; + } } -export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { +export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { const podSelector = pods.map(pod => pod.getName()).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -45,23 +50,22 @@ export function getMetricsForPods(pods: Pod[], namespace: string, selector = "po }); } -export interface IPodMetrics { - [metric: string]: T; - cpuUsage: T; - memoryUsage: T; - fsUsage: T; - fsWrites: T; - fsReads: T; - networkReceive: T; - networkTransmit: T; - cpuRequests?: T; - cpuLimits?: T; - memoryRequests?: T; - memoryLimits?: T; +export interface PodMetricData extends Partial> { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; + cpuRequests?: MetricData; + cpuLimits?: MetricData; + memoryRequests?: MetricData; + memoryLimits?: MetricData; } // Reference: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#read-log-pod-v1-core -export interface IPodLogsQuery { +export interface PodLogsQuery { container?: string; tailLines?: number; timestamps?: boolean; @@ -70,7 +74,7 @@ export interface IPodLogsQuery { previous?: boolean; } -export enum PodStatus { +export enum PodStatusPhase { TERMINATED = "Terminated", FAILED = "Failed", PENDING = "Pending", @@ -79,26 +83,41 @@ export enum PodStatus { EVICTED = "Evicted", } -export interface IPodContainer extends Partial> { +export interface ContainerPort { + containerPort: number; + hostIP?: string; + hostPort?: number; + name?: string; + protocol?: "UDP" | "TCP" | "SCTP"; +} + +export interface VolumeMount { + name: string; + readOnly?: boolean; + mountPath: string; + mountPropagation?: string; + subPath?: string; + subPathExpr?: string; +} + +export interface PodContainer extends Partial> { name: string; image: string; command?: string[]; args?: string[]; - ports?: { - name?: string; - containerPort: number; - protocol: string; - }[]; + ports?: ContainerPort[]; resources?: { - limits: { + limits?: { cpu: string; memory: string; }; - requests: { + requests?: { cpu: string; memory: string; }; }; + terminationMessagePath?: string; + terminationMessagePolicy?: string; env?: { name: string; value?: string; @@ -121,12 +140,8 @@ export interface IPodContainer extends Partial & { +export type PodSpecVolume = RequireExactlyOne & { name: string; }; -export class Pod extends WorkloadKubeObject { + +export interface HostAlias { + ip: string; + hostnames: string[]; +} + +export interface SELinuxOptions { + level?: string; + role?: string; + type?: string; + user?: string; +} + +export interface SeccompProfile { + localhostProfile?: string; + type: string; +} + +export interface Sysctl { + name: string; + value: string; +} + +export interface WindowsSecurityContextOptions { + labelSelector?: LabelSelector; + maxSkew: number; + topologyKey: string; + whenUnsatisfiable: string; +} + +export interface PodSecurityContext { + fsGroup?: number; + fsGroupChangePolicy?: string; + runAsGroup?: number; + runAsNonRoot?: boolean; + runAsUser?: number; + seLinuxOptions?: SELinuxOptions; + seccompProfile?: SeccompProfile; + supplementalGroups?: number[]; + sysctls?: Sysctl; + windowsOptions?: WindowsSecurityContextOptions; +} + +export interface TopologySpreadConstraint { + +} + +export interface PodSpec { + activeDeadlineSeconds?: number; + affinity?: Affinity; + automountServiceAccountToken?: boolean; + containers?: PodContainer[]; + dnsPolicy?: string; + enableServiceLinks?: boolean; + ephemeralContainers?: unknown[]; + hostAliases?: HostAlias[]; + hostIPC?: boolean; + hostname?: string; + hostNetwork?: boolean; + hostPID?: boolean; + imagePullSecrets?: LocalObjectReference[]; + initContainers?: PodContainer[]; + nodeName?: string; + nodeSelector?: Partial>; + overhead?: Partial>; + preemptionPolicy?: string; + priority?: number; + priorityClassName?: string; + readinessGates?: unknown[]; + restartPolicy?: string; + runtimeClassName?: string; + schedulerName?: string; + securityContext?: PodSecurityContext; + serviceAccount?: string; + serviceAccountName?: string; + setHostnameAsFQDN?: boolean; + shareProcessNamespace?: boolean; + subdomain?: string; + terminationGracePeriodSeconds?: number; + tolerations?: Toleration[]; + topologySpreadConstraints?: TopologySpreadConstraint[]; + volumes?: PodSpecVolume[]; +} + +export interface PodCondition { + lastProbeTime?: number; + lastTransitionTime?: string; + message?: string; + reason?: string; + type: string; + status: string; +} + +export interface PodStatus { + phase: string; + conditions: PodCondition[]; + hostIP: string; + podIP: string; + podIPs?: { + ip: string; + }[]; + startTime: string; + initContainerStatuses?: PodContainerStatus[]; + containerStatuses?: PodContainerStatus[]; + qosClass?: string; + reason?: string; +} + +export class Pod extends KubeObject { static kind = "Pod"; static namespaced = true; static apiBase = "/api/v1/pods"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getAffinityNumber() { + return Object.keys(this.getAffinity()).length; } - declare spec?: { - volumes?: PodVolume[]; - initContainers: IPodContainer[]; - containers: IPodContainer[]; - restartPolicy?: string; - terminationGracePeriodSeconds?: number; - activeDeadlineSeconds?: number; - dnsPolicy?: string; - serviceAccountName: string; - serviceAccount: string; - automountServiceAccountToken?: boolean; - priority?: number; - priorityClassName?: string; - nodeName?: string; - nodeSelector?: { - [selector: string]: string; - }; - securityContext?: {}; - imagePullSecrets?: LocalObjectReference[]; - hostNetwork?: boolean; - hostPID?: boolean; - hostIPC?: boolean; - shareProcessNamespace?: boolean; - hostname?: string; - subdomain?: string; - schedulerName?: string; - tolerations?: { - key?: string; - operator?: string; - effect?: string; - tolerationSeconds?: number; - value?: string; - }[]; - hostAliases?: { - ip: string; - hostnames: string[]; - }; - affinity?: IAffinity; - }; - declare status?: { - phase: string; - conditions: { - type: string; - status: string; - lastProbeTime: number; - lastTransitionTime: string; - }[]; - hostIP: string; - podIP: string; - podIPs?: { - ip: string; - }[]; - startTime: string; - initContainerStatuses?: IPodContainerStatus[]; - containerStatuses?: IPodContainerStatus[]; - qosClass?: string; - reason?: string; - }; - getInitContainers() { - return this.spec?.initContainers || []; + return this.spec?.initContainers ?? []; } getContainers() { - return this.spec?.containers || []; + return this.spec?.containers ?? []; } getAllContainers() { @@ -719,7 +800,7 @@ export class Pod extends WorkloadKubeObject { getRunningContainers() { const runningContainerNames = new Set( this.getContainerStatuses() - .filter(({ state }) => state.running) + .filter(({ state }) => state?.running) .map(({ name }) => name), ); @@ -752,10 +833,10 @@ export class Pod extends WorkloadKubeObject { } getPriorityClassName() { - return this.spec.priorityClassName || ""; + return this.spec?.priorityClassName || ""; } - getStatus(): PodStatus { + getStatus(): PodStatusPhase { const phase = this.getStatusPhase(); const reason = this.getReason(); const trueConditionTypes = new Set(this.getConditions() @@ -763,28 +844,28 @@ export class Pod extends WorkloadKubeObject { .map(({ type }) => type)); const isInGoodCondition = ["Initialized", "Ready"].every(condition => trueConditionTypes.has(condition)); - if (reason === PodStatus.EVICTED) { - return PodStatus.EVICTED; + if (reason === PodStatusPhase.EVICTED) { + return PodStatusPhase.EVICTED; } - if (phase === PodStatus.FAILED) { - return PodStatus.FAILED; + if (phase === PodStatusPhase.FAILED) { + return PodStatusPhase.FAILED; } - if (phase === PodStatus.SUCCEEDED) { - return PodStatus.SUCCEEDED; + if (phase === PodStatusPhase.SUCCEEDED) { + return PodStatusPhase.SUCCEEDED; } - if (phase === PodStatus.RUNNING && isInGoodCondition) { - return PodStatus.RUNNING; + if (phase === PodStatusPhase.RUNNING && isInGoodCondition) { + return PodStatusPhase.RUNNING; } - return PodStatus.PENDING; + return PodStatusPhase.PENDING; } // Returns pod phase or container error if occurred getStatusMessage(): string { - if (this.getReason() === PodStatus.EVICTED) { + if (this.getReason() === PodStatusPhase.EVICTED) { return "Evicted"; } @@ -800,31 +881,30 @@ export class Pod extends WorkloadKubeObject { } getConditions() { - return this.status?.conditions || []; + return this.status?.conditions ?? []; } getVolumes() { - return this.spec.volumes || []; + return this.spec?.volumes ?? []; } getSecrets(): string[] { return this.getVolumes() - .filter(vol => vol.secret) - .map(vol => vol.secret.secretName); + .map(vol => vol.secret?.secretName) + .filter(isDefined); } getNodeSelectors(): string[] { - const { nodeSelector = {}} = this.spec; - - return Object.entries(nodeSelector).map(values => values.join(": ")); + return Object.entries(this.spec?.nodeSelector ?? {}) + .map(values => values.join(": ")); } getTolerations() { - return this.spec.tolerations || []; + return this.spec?.tolerations ?? []; } - getAffinity(): IAffinity { - return this.spec.affinity; + getAffinity(): Affinity { + return this.spec?.affinity ?? {}; } hasIssues() { @@ -843,19 +923,19 @@ export class Pod extends WorkloadKubeObject { return this.getStatusPhase() !== "Running"; } - getLivenessProbe(container: IPodContainer) { + getLivenessProbe(container: PodContainer) { return this.getProbe(container, "livenessProbe"); } - getReadinessProbe(container: IPodContainer) { + getReadinessProbe(container: PodContainer) { return this.getProbe(container, "readinessProbe"); } - getStartupProbe(container: IPodContainer) { + getStartupProbe(container: PodContainer) { return this.getProbe(container, "startupProbe"); } - private getProbe(container: IPodContainer, field: PodContainerProbe): string[] { + private getProbe(container: PodContainer, field: PodContainerProbe): string[] { const probe: string[] = []; const probeData = container[field]; @@ -907,30 +987,17 @@ export class Pod extends WorkloadKubeObject { return probe; } - getNodeName() { - return this.spec.nodeName; + getNodeName(): string | undefined { + return this.spec?.nodeName; } getSelectedNodeOs(): string | undefined { - return this.spec.nodeSelector?.["kubernetes.io/os"] || this.spec.nodeSelector?.["beta.kubernetes.io/os"]; + return this.spec?.nodeSelector?.["kubernetes.io/os"] || this.spec?.nodeSelector?.["beta.kubernetes.io/os"]; } getIPs(): string[] { - if(!this.status.podIPs) return []; - const podIPs = this.status.podIPs; + const podIPs = this.status?.podIPs ?? []; return podIPs.map(value => value.ip); } } - -let podsApi: PodsApi; - -if (isClusterPageContext()) { - podsApi = new PodsApi({ - objectConstructor: Pod, - }); -} - -export { - podsApi, -}; diff --git a/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts b/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts deleted file mode 100644 index 5797ddf877..0000000000 --- a/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { autoBind } from "../../utils"; -import type { LabelSelector } from "../kube-object"; -import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export interface PodDisruptionBudget { - spec: { - minAvailable: string; - maxUnavailable: string; - selector: LabelSelector; - }; - status: { - currentHealthy: number; - desiredHealthy: number; - disruptionsAllowed: number; - expectedPods: number; - }; -} - -export class PodDisruptionBudget extends KubeObject { - static kind = "PodDisruptionBudget"; - static namespaced = true; - static apiBase = "/apis/policy/v1beta1/poddisruptionbudgets"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - - getSelectors() { - const selector = this.spec.selector; - - return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); - } - - getMinAvailable() { - return this.spec.minAvailable || "N/A"; - } - - getMaxUnavailable() { - return this.spec.maxUnavailable || "N/A"; - } - - getCurrentHealthy() { - return this.status.currentHealthy; - } - - getDesiredHealthy() { - return this.status.desiredHealthy; - } - -} - -let pdbApi: KubeApi; - -if (isClusterPageContext()) { - pdbApi = new KubeApi({ - objectConstructor: PodDisruptionBudget, - }); -} - -export { - pdbApi, -}; diff --git a/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts b/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts deleted file mode 100644 index 9220d65e77..0000000000 --- a/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { autoBind } from "../../utils"; -import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export interface PodSecurityPolicy { - spec: { - allowPrivilegeEscalation?: boolean; - allowedCSIDrivers?: { - name: string; - }[]; - allowedCapabilities: string[]; - allowedFlexVolumes?: { - driver: string; - }[]; - allowedHostPaths?: { - pathPrefix: string; - readOnly: boolean; - }[]; - allowedProcMountTypes?: string[]; - allowedUnsafeSysctls?: string[]; - defaultAddCapabilities?: string[]; - defaultAllowPrivilegeEscalation?: boolean; - forbiddenSysctls?: string[]; - fsGroup?: { - rule: string; - ranges: { max: number; min: number }[]; - }; - hostIPC?: boolean; - hostNetwork?: boolean; - hostPID?: boolean; - hostPorts?: { - max: number; - min: number; - }[]; - privileged?: boolean; - readOnlyRootFilesystem?: boolean; - requiredDropCapabilities?: string[]; - runAsGroup?: { - ranges: { max: number; min: number }[]; - rule: string; - }; - runAsUser?: { - rule: string; - ranges: { max: number; min: number }[]; - }; - runtimeClass?: { - allowedRuntimeClassNames: string[]; - defaultRuntimeClassName: string; - }; - seLinux?: { - rule: string; - seLinuxOptions: { - level: string; - role: string; - type: string; - user: string; - }; - }; - supplementalGroups?: { - rule: string; - ranges: { max: number; min: number }[]; - }; - volumes?: string[]; - }; -} - -export class PodSecurityPolicy extends KubeObject { - static kind = "PodSecurityPolicy"; - static namespaced = false; - static apiBase = "/apis/policy/v1beta1/podsecuritypolicies"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - - isPrivileged() { - return !!this.spec.privileged; - } - - getVolumes() { - return this.spec.volumes || []; - } - - getRules() { - const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec; - - return { - fsGroup: fsGroup ? fsGroup.rule : "", - runAsGroup: runAsGroup ? runAsGroup.rule : "", - runAsUser: runAsUser ? runAsUser.rule : "", - supplementalGroups: supplementalGroups ? supplementalGroups.rule : "", - seLinux: seLinux ? seLinux.rule : "", - }; - } -} - -let pspApi: KubeApi; - -if (isClusterPageContext()) { - pspApi = new KubeApi({ - objectConstructor: PodSecurityPolicy, - }); -} - -export { - pspApi, -}; diff --git a/src/common/k8s-api/endpoints/replica-set.api.injectable.ts b/src/common/k8s-api/endpoints/replica-set.api.injectable.ts new file mode 100644 index 0000000000..8831173403 --- /dev/null +++ b/src/common/k8s-api/endpoints/replica-set.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ReplicaSetApi } from "./replica-set.api"; + +const replicaSetApiInjectable = getInjectable({ + id: "replica-set-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "replicaSetApi is only available in certain environments"); + + return new ReplicaSetApi(); + }, +}); + +export default replicaSetApiInjectable; diff --git a/src/common/k8s-api/endpoints/replica-set.api.ts b/src/common/k8s-api/endpoints/replica-set.api.ts index 302ad96b17..2ad571d4e9 100644 --- a/src/common/k8s-api/endpoints/replica-set.api.ts +++ b/src/common/k8s-api/endpoints/replica-set.api.ts @@ -3,25 +3,30 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; -import { autoBind } from "../../../renderer/utils"; -import { WorkloadKubeObject } from "../workload-kube-object"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodContainer, IPodMetrics, Pod } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { PodMetricData } from "./pod.api"; +import type { KubeObjectScope, KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; export class ReplicaSetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ReplicaSet, + }); + } + protected getScaleApiUrl(params: { namespace: string; name: string }) { return `${this.getUrl(params)}/scale`; } - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + return (status as { replicas: number })?.replicas; } scale(params: { namespace: string; name: string }, replicas: number) { @@ -36,7 +41,7 @@ export class ReplicaSetApi extends KubeApi { } } -export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: string, selector = ""): Promise { +export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: string, selector = ""): Promise { const podSelector = replicasets.map(replicaset => `${replicaset.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -53,72 +58,65 @@ export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: s }); } -export class ReplicaSet extends WorkloadKubeObject { +export interface ReplicaSetSpec { + replicas?: number; + selector: LabelSelector; + template?: PodTemplateSpec; + minReadySeconds?: number; +} + +export interface ReplicaSetStatus extends KubeObjectStatus { + replicas: number; + fullyLabeledReplicas?: number; + readyReplicas?: number; + availableReplicas?: number; + observedGeneration?: number; +} + +export class ReplicaSet extends KubeObject { static kind = "ReplicaSet"; static namespaced = true; static apiBase = "/apis/apps/v1/replicasets"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - replicas?: number; - selector: LabelSelector; - template?: { - metadata: { - labels: { - app: string; - }; - }; - spec?: Pod["spec"]; - }; - minReadySeconds?: number; - }; - declare status: { - replicas: number; - fullyLabeledReplicas?: number; - readyReplicas?: number; - availableReplicas?: number; - observedGeneration?: number; - conditions?: { - type: string; - status: string; - lastUpdateTime: string; - lastTransitionTime: string; - reason: string; - message: string; - }[]; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template?.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template?.metadata?.labels); + } + + getTolerations() { + return this.spec.template?.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template?.spec?.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getDesired() { - return this.spec.replicas || 0; + return this.spec.replicas ?? 0; } getCurrent() { - return this.status.availableReplicas || 0; + return this.status?.availableReplicas ?? 0; } getReady() { - return this.status.readyReplicas || 0; + return this.status?.readyReplicas ?? 0; } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + const containers = this.spec.template?.spec?.containers ?? []; - return [...containers].map(container => container.image); + return containers.map(container => container.image); } } - -let replicaSetApi: ReplicaSetApi; - -if (isClusterPageContext()) { - replicaSetApi = new ReplicaSetApi({ - objectConstructor: ReplicaSet, - }); -} - -export { - replicaSetApi, -}; diff --git a/src/common/k8s-api/endpoints/resource-applier.api.ts b/src/common/k8s-api/endpoints/resource-applier.api.ts index 0bc5d4e551..9f0c3bb6cd 100644 --- a/src/common/k8s-api/endpoints/resource-applier.api.ts +++ b/src/common/k8s-api/endpoints/resource-applier.api.ts @@ -16,7 +16,7 @@ export async function update(resource: object | string): Promise("/stack", { data: resource }); } -export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise { +export async function patch(name: string, kind: string, ns: string | undefined, patch: Patch): Promise { return apiBase.patch("/stack", { data: { name, diff --git a/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts b/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts new file mode 100644 index 0000000000..6cedcba453 --- /dev/null +++ b/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ResourceQuotaApi } from "./resource-quota.api"; + +const resourceQuotaApiInjectable = getInjectable({ + id: "resource-quota-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "resourceQuotaApi is only available in certain environments"); + + return new ResourceQuotaApi(); + }, +}); + +export default resourceQuotaApiInjectable; diff --git a/src/common/k8s-api/endpoints/resource-quota.api.ts b/src/common/k8s-api/endpoints/resource-quota.api.ts index eda54a7249..b9af4ad8d7 100644 --- a/src/common/k8s-api/endpoints/resource-quota.api.ts +++ b/src/common/k8s-api/endpoints/resource-quota.api.ts @@ -3,13 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export interface IResourceQuotaValues { - [quota: string]: string; +export type IResourceQuotaValues = Partial> & { // Compute Resource Quota "limits.cpu"?: string; "limits.memory"?: string; @@ -33,46 +32,39 @@ export interface IResourceQuotaValues { "count/jobs.batch"?: string; "count/cronjobs.batch"?: string; "count/deployments.extensions"?: string; -} +}; -export interface ResourceQuota { - spec: { - hard: IResourceQuotaValues; - scopeSelector?: { - matchExpressions: { - operator: string; - scopeName: string; - values: string[]; - }[]; - }; - }; - - status: { - hard: IResourceQuotaValues; - used: IResourceQuotaValues; +export interface ResourceQuotaSpec { + hard: IResourceQuotaValues; + scopeSelector?: { + matchExpressions: { + operator: string; + scopeName: string; + values: string[]; + }[]; }; } -export class ResourceQuota extends KubeObject { - static kind = "ResourceQuota"; - static namespaced = true; - static apiBase = "/api/v1/resourcequotas"; +export interface ResourceQuotaStatus { + hard: IResourceQuotaValues; + used: IResourceQuotaValues; +} + +export class ResourceQuota extends KubeObject { + static readonly kind = "ResourceQuota"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/resourcequotas"; getScopeSelector() { - const { matchExpressions = [] } = this.spec.scopeSelector || {}; - - return matchExpressions; + return this.spec.scopeSelector?.matchExpressions ?? []; } } -let resourceQuotaApi: KubeApi; - -if (isClusterPageContext()) { - resourceQuotaApi = new KubeApi({ - objectConstructor: ResourceQuota, - }); +export class ResourceQuotaApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: ResourceQuota, + ...opts, + }); + } } - -export { - resourceQuotaApi, -}; diff --git a/src/common/k8s-api/endpoints/role-binding.api.injectable.ts b/src/common/k8s-api/endpoints/role-binding.api.injectable.ts new file mode 100644 index 0000000000..8e0f04b92c --- /dev/null +++ b/src/common/k8s-api/endpoints/role-binding.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { RoleBindingApi } from "./role-binding.api"; + +const roleBindingApiInjectable = getInjectable({ + id: "role-binding-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "roleBindingApi is only available in certain environments"); + + return new RoleBindingApi(); + }, +}); + +export default roleBindingApiInjectable; diff --git a/src/common/k8s-api/endpoints/role-binding.api.ts b/src/common/k8s-api/endpoints/role-binding.api.ts index dca2510dd3..acc30fe62e 100644 --- a/src/common/k8s-api/endpoints/role-binding.api.ts +++ b/src/common/k8s-api/endpoints/role-binding.api.ts @@ -3,38 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../utils"; +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { RoleRef } from "./types/role-ref"; +import type { Subject } from "./types/subject"; -export type RoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; - -export interface RoleBindingSubject { - kind: RoleBindingSubjectKind; - name: string; - namespace?: string; - apiGroup?: string; +export interface RoleBindingData extends KubeJsonApiData, void, void> { + subjects?: Subject[]; + roleRef: RoleRef; } -export interface RoleBinding { - subjects?: RoleBindingSubject[]; - roleRef: { - kind: string; - name: string; - apiGroup?: string; - }; -} +export class RoleBinding extends KubeObject { + static readonly kind = "RoleBinding"; + static readonly namespaced = true; + static readonly apiBase = "/apis/rbac.authorization.k8s.io/v1/rolebindings"; -export class RoleBinding extends KubeObject { - static kind = "RoleBinding"; - static namespaced = true; - static apiBase = "/apis/rbac.authorization.k8s.io/v1/rolebindings"; + subjects?: Subject[]; + roleRef: RoleRef; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + constructor({ subjects, roleRef, ...rest }: RoleBindingData) { + super(rest); + this.subjects = subjects; + this.roleRef = roleRef; } getSubjects() { @@ -46,14 +39,11 @@ export class RoleBinding extends KubeObject { } } -let roleBindingApi: KubeApi; - -if (isClusterPageContext()) { - roleBindingApi = new KubeApi({ - objectConstructor: RoleBinding, - }); +export class RoleBindingApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: RoleBinding, + }); + } } - -export { - roleBindingApi, -}; diff --git a/src/common/k8s-api/endpoints/role.api.injectable.ts b/src/common/k8s-api/endpoints/role.api.injectable.ts new file mode 100644 index 0000000000..0b6949357b --- /dev/null +++ b/src/common/k8s-api/endpoints/role.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { RoleApi } from "./role.api"; + +const roleApiInjectable = getInjectable({ + id: "role-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "roleApi is only available in certain environments"); + + return new RoleApi(); + }, +}); + +export default roleApiInjectable; diff --git a/src/common/k8s-api/endpoints/role.api.ts b/src/common/k8s-api/endpoints/role.api.ts index ee960bb934..a98c580088 100644 --- a/src/common/k8s-api/endpoints/role.api.ts +++ b/src/common/k8s-api/endpoints/role.api.ts @@ -3,37 +3,38 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { PolicyRule } from "./types/policy-rule"; -export interface Role { - rules: { - verbs: string[]; - apiGroups: string[]; - resources: string[]; - resourceNames?: string[]; - }[]; +export interface RoleData extends KubeJsonApiData, void, void> { + rules?: PolicyRule[]; } -export class Role extends KubeObject { - static kind = "Role"; - static namespaced = true; - static apiBase = "/apis/rbac.authorization.k8s.io/v1/roles"; +export class Role extends KubeObject { + static readonly kind = "Role"; + static readonly namespaced = true; + static readonly apiBase = "/apis/rbac.authorization.k8s.io/v1/roles"; + rules?: PolicyRule[]; + + constructor({ rules, ...rest }: RoleData) { + super(rest); + this.rules = rules; + } getRules() { return this.rules || []; } } -let roleApi: KubeApi; - -if (isClusterPageContext()) { - roleApi = new KubeApi({ - objectConstructor: Role, - }); +export class RoleApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Role, + }); + } } - -export{ - roleApi, -}; diff --git a/src/common/k8s-api/endpoints/secret.api.injectable.ts b/src/common/k8s-api/endpoints/secret.api.injectable.ts new file mode 100644 index 0000000000..67ebd3d541 --- /dev/null +++ b/src/common/k8s-api/endpoints/secret.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { SecretApi } from "./secret.api"; + +const secretApiInjectable = getInjectable({ + id: "secret-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "secretApi is only available in certain environments"); + + return new SecretApi(); + }, +}); + +export default secretApiInjectable; diff --git a/src/common/k8s-api/endpoints/secret.api.ts b/src/common/k8s-api/endpoints/secret.api.ts index 93953ebf48..8a71d914cd 100644 --- a/src/common/k8s-api/endpoints/secret.api.ts +++ b/src/common/k8s-api/endpoints/secret.api.ts @@ -3,11 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { KubeJsonApiData } from "../kube-json-api"; import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export enum SecretType { Opaque = "Opaque", @@ -20,34 +21,41 @@ export enum SecretType { BootstrapToken = "bootstrap.kubernetes.io/token", } -export interface ISecretRef { - key?: string; - name: string; -} +export const reverseSecretTypeMap = { + [SecretType.Opaque]: "Opaque", + [SecretType.ServiceAccountToken]: "ServiceAccountToken", + [SecretType.Dockercfg]: "Dockercfg", + [SecretType.DockerConfigJson]: "DockerConfigJson", + [SecretType.BasicAuth]: "BasicAuth", + [SecretType.SSHAuth]: "SSHAuth", + [SecretType.TLS]: "TLS", + [SecretType.BootstrapToken]: "BootstrapToken", +}; export interface SecretReference { name: string; namespace?: string; } -export interface SecretData extends KubeJsonApiData { +export interface SecretData extends KubeJsonApiData, void, void> { type: SecretType; - data?: Record; + data?: Partial>; } -export class Secret extends KubeObject { - static kind = "Secret"; - static namespaced = true; - static apiBase = "/api/v1/secrets"; +export class Secret extends KubeObject { + static readonly kind = "Secret"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/secrets"; - declare type: SecretType; - declare data: Record; + type: SecretType; + data: Partial>; - constructor(data: SecretData) { - super(data); + constructor({ data = {}, type, ...rest }: SecretData) { + super(rest); autoBind(this); - this.data ??= {}; + this.data = data; + this.type = type; } getKeys(): string[] { @@ -59,14 +67,11 @@ export class Secret extends KubeObject { } } -let secretsApi: KubeApi; - -if (isClusterPageContext()) { - secretsApi = new KubeApi({ - objectConstructor: Secret, - }); +export class SecretApi extends KubeApi { + constructor(options: DerivedKubeApiOptions = {}) { + super({ + ...options, + objectConstructor: Secret, + }); + } } - -export { - secretsApi, -}; diff --git a/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.injectable.ts b/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.injectable.ts new file mode 100644 index 0000000000..cc77399c02 --- /dev/null +++ b/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { SelfSubjectRulesReviewApi } from "./self-subject-rules-reviews.api"; + +const selfSubjectRulesReviewApiInjectable = getInjectable({ + id: "self-subject-rules-review-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "selfSubjectRulesReviewApi is only available in certain environments"); + + return new SelfSubjectRulesReviewApi(); + }, +}); + +export default selfSubjectRulesReviewApiInjectable; diff --git a/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts b/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.ts similarity index 82% rename from src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts rename to src/common/k8s-api/endpoints/self-subject-rules-reviews.api.ts index c83271b47a..61714d9ca3 100644 --- a/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts +++ b/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.ts @@ -4,11 +4,18 @@ */ import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export class SelfSubjectRulesReviewApi extends KubeApi { - create({ namespace = "default" }): Promise { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: SelfSubjectRulesReview, + }); + } + + create({ namespace = "default" }) { return super.create({}, { spec: { namespace, @@ -71,15 +78,3 @@ export class SelfSubjectRulesReview extends KubeObject { } } -let selfSubjectRulesReviewApi: SelfSubjectRulesReviewApi; - -if (isClusterPageContext()) { - selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({ - objectConstructor: SelfSubjectRulesReview, - }); -} - -export { - selfSubjectRulesReviewApi, -}; - diff --git a/src/common/k8s-api/endpoints/service-account.api.injectable.ts b/src/common/k8s-api/endpoints/service-account.api.injectable.ts new file mode 100644 index 0000000000..3b48b0eb98 --- /dev/null +++ b/src/common/k8s-api/endpoints/service-account.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ServiceAccountApi } from "./service-account.api"; + +const serviceAccountApiInjectable = getInjectable({ + id: "service-account-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "serviceAccountApi is only available in certain environments"); + + return new ServiceAccountApi(); + }, +}); + +export default serviceAccountApiInjectable; diff --git a/src/common/k8s-api/endpoints/service-account.api.ts b/src/common/k8s-api/endpoints/service-account.api.ts new file mode 100644 index 0000000000..1f3439d29f --- /dev/null +++ b/src/common/k8s-api/endpoints/service-account.api.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, LocalObjectReference, ObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; + +export interface ServiceAccountData extends KubeJsonApiData, void, void> { + automountServiceAccountToken?: boolean; + imagePullSecrets?: LocalObjectReference[]; + secrets?: ObjectReference[]; +} + +export class ServiceAccount extends KubeObject { + static readonly kind = "ServiceAccount"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/serviceaccounts"; + + automountServiceAccountToken?: boolean; + imagePullSecrets?: LocalObjectReference[]; + secrets?: ObjectReference[]; + + constructor({ + automountServiceAccountToken, + imagePullSecrets, + secrets, + ...rest + }: ServiceAccountData) { + super(rest); + this.automountServiceAccountToken = automountServiceAccountToken; + this.imagePullSecrets = imagePullSecrets; + this.secrets = secrets; + } + + getSecrets() { + return this.secrets || []; + } + + getImagePullSecrets() { + return this.imagePullSecrets || []; + } +} + +export class ServiceAccountApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ServiceAccount, + }); + } +} diff --git a/src/common/k8s-api/endpoints/service-accounts.api.ts b/src/common/k8s-api/endpoints/service-accounts.api.ts deleted file mode 100644 index 02cdd453d8..0000000000 --- a/src/common/k8s-api/endpoints/service-accounts.api.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { autoBind } from "../../utils"; -import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export interface ServiceAccount { - secrets?: { - name: string; - }[]; - imagePullSecrets?: { - name: string; - }[]; -} - -export class ServiceAccount extends KubeObject { - static kind = "ServiceAccount"; - static namespaced = true; - static apiBase = "/api/v1/serviceaccounts"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - - getSecrets() { - return this.secrets || []; - } - - getImagePullSecrets() { - return this.imagePullSecrets || []; - } -} - -let serviceAccountsApi: KubeApi; - -if (isClusterPageContext()) { - serviceAccountsApi = new KubeApi({ - objectConstructor: ServiceAccount, - }); -} - -export { - serviceAccountsApi, -}; diff --git a/src/common/k8s-api/endpoints/service.api.injectable.ts b/src/common/k8s-api/endpoints/service.api.injectable.ts new file mode 100644 index 0000000000..db5f62b89e --- /dev/null +++ b/src/common/k8s-api/endpoints/service.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ServiceApi } from "./service.api"; + +const serviceApiInjectable = getInjectable({ + id: "service-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "serviceApi is only available in certain environments"); + + return new ServiceApi(); + }, +}); + +export default serviceApiInjectable; diff --git a/src/common/k8s-api/endpoints/service.api.ts b/src/common/k8s-api/endpoints/service.api.ts index 6a3eb5ba7b..47c655e15f 100644 --- a/src/common/k8s-api/endpoints/service.api.ts +++ b/src/common/k8s-api/endpoints/service.api.ts @@ -3,11 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../../renderer/utils"; +import type { KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface ServicePort { name?: string; @@ -31,47 +30,40 @@ export class ServicePort { } } -export interface Service { - spec: { - type: string; - clusterIP: string; - clusterIPs?: string[]; - externalTrafficPolicy?: string; - externalName?: string; - loadBalancerIP?: string; - loadBalancerSourceRanges?: string[]; - sessionAffinity: string; - selector: { [key: string]: string }; - ports: ServicePort[]; - healthCheckNodePort?: number; - externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips - topologyKeys?: string[]; - ipFamilies?: string[]; - ipFamilyPolicy?: string; - allocateLoadBalancerNodePorts?: boolean; - loadBalancerClass?: string; - internalTrafficPolicy?: string; - }; +export interface ServiceSpec { + type: string; + clusterIP: string; + clusterIPs?: string[]; + externalTrafficPolicy?: string; + externalName?: string; + loadBalancerIP?: string; + loadBalancerSourceRanges?: string[]; + sessionAffinity: string; + selector: Partial>; + ports: ServicePort[]; + healthCheckNodePort?: number; + externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips + topologyKeys?: string[]; + ipFamilies?: string[]; + ipFamilyPolicy?: string; + allocateLoadBalancerNodePorts?: boolean; + loadBalancerClass?: string; + internalTrafficPolicy?: string; +} - status: { - loadBalancer?: { - ingress?: { - ip?: string; - hostname?: string; - }[]; - }; +export interface ServiceStatus { + loadBalancer?: { + ingress?: { + ip?: string; + hostname?: string; + }[]; }; } -export class Service extends KubeObject { - static kind = "Service"; - static namespaced = true; - static apiBase = "/api/v1/services"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class Service extends KubeObject { + static readonly kind = "Service"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/services"; getClusterIp() { return this.spec.clusterIP; @@ -112,7 +104,7 @@ export class Service extends KubeObject { } getLoadBalancer() { - return this.status.loadBalancer; + return this.status?.loadBalancer; } isActive() { @@ -132,14 +124,11 @@ export class Service extends KubeObject { } } -let serviceApi: KubeApi; - -if (isClusterPageContext()) { - serviceApi = new KubeApi({ - objectConstructor: Service, - }); +export class ServiceApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Service, + }); + } } - -export { - serviceApi, -}; diff --git a/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts b/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts new file mode 100644 index 0000000000..a28dd5e967 --- /dev/null +++ b/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { StatefulSetApi } from "./stateful-set.api"; + +const statefulSetApiInjectable = getInjectable({ + id: "stateful-set-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "statefulSetApi is only available in certain environments"); + + return new StatefulSetApi(); + }, +}); + +export default statefulSetApiInjectable; diff --git a/src/common/k8s-api/endpoints/stateful-set.api.ts b/src/common/k8s-api/endpoints/stateful-set.api.ts index b388ca8d44..2e263d03bf 100644 --- a/src/common/k8s-api/endpoints/stateful-set.api.ts +++ b/src/common/k8s-api/endpoints/stateful-set.api.ts @@ -3,17 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { PodMetricData } from "./pod.api"; +import type { KubeObjectScope, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; +import type { PersistentVolumeClaimTemplateSpec } from "./types/persistent-volume-claim-template-spec"; export class StatefulSetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: StatefulSet, + }); + } + protected getScaleApiUrl(params: { namespace: string; name: string }) { return `${this.getUrl(params)}/scale`; } @@ -40,7 +46,7 @@ export class StatefulSetApi extends KubeApi { } } -export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace: string, selector = ""): Promise { +export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace: string, selector = ""): Promise { const podSelector = statefulSets.map(statefulset => `${statefulset.getName()}-[[:digit:]]+`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -57,74 +63,52 @@ export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace }); } -export class StatefulSet extends WorkloadKubeObject { - static kind = "StatefulSet"; - static namespaced = true; - static apiBase = "/apis/apps/v1/statefulsets"; +export interface StatefulSetSpec { + serviceName: string; + replicas: number; + selector: LabelSelector; + template: PodTemplateSpec; + volumeClaimTemplates: PersistentVolumeClaimTemplateSpec[]; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); +export interface StatefulSetStatus { + observedGeneration: number; + replicas: number; + currentReplicas: number; + readyReplicas: number; + currentRevision: string; + updateRevision: string; + collisionCount: number; +} + +export class StatefulSet extends KubeObject { + static readonly kind = "StatefulSet"; + static readonly namespaced = true; + static readonly apiBase = "/apis/apps/v1/statefulsets"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - serviceName: string; - replicas: number; - selector: LabelSelector; - template: { - metadata: { - labels: { - app: string; - }; - }; - spec: { - containers: null | { - name: string; - image: string; - ports: { - containerPort: number; - name: string; - }[]; - volumeMounts: { - name: string; - mountPath: string; - }[]; - }[]; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - tolerations: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - }; - }; - volumeClaimTemplates: { - metadata: { - name: string; - }; - spec: { - accessModes: string[]; - resources: { - requests: { - storage: string; - }; - }; - }; - }[]; - }; - declare status: { - observedGeneration: number; - replicas: number; - currentReplicas: number; - readyReplicas: number; - currentRevision: string; - updateRevision: string; - collisionCount: number; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata?.labels); + } + + getTolerations() { + return this.spec.template.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec?.affinity ?? {}; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity()).length; + } getReplicas() { return this.spec.replicas || 0; @@ -136,15 +120,3 @@ export class StatefulSet extends WorkloadKubeObject { return containers.map(container => container.image); } } - -let statefulSetApi: StatefulSetApi; - -if (isClusterPageContext()) { - statefulSetApi = new StatefulSetApi({ - objectConstructor: StatefulSet, - }); -} - -export { - statefulSetApi, -}; diff --git a/src/common/k8s-api/endpoints/storage-class.api.injectable.ts b/src/common/k8s-api/endpoints/storage-class.api.injectable.ts new file mode 100644 index 0000000000..8594e231a0 --- /dev/null +++ b/src/common/k8s-api/endpoints/storage-class.api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { StorageClassApi } from "./storage-class.api"; + +const storageClassApiInjectable = getInjectable({ + id: "storage-class-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "storageClassApi is only available in certain environments"); + + return new StorageClassApi(); + }, +}); + +export default storageClassApiInjectable; diff --git a/src/common/k8s-api/endpoints/storage-class.api.ts b/src/common/k8s-api/endpoints/storage-class.api.ts index c01c89f5b7..2ded77d746 100644 --- a/src/common/k8s-api/endpoints/storage-class.api.ts +++ b/src/common/k8s-api/endpoints/storage-class.api.ts @@ -4,29 +4,63 @@ */ import { autoBind } from "../../utils"; +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface StorageClass { - provisioner: string; // e.g. "storage.k8s.io/v1" - mountOptions?: string[]; - volumeBindingMode: string; - reclaimPolicy: string; - parameters: { - [param: string]: string; // every provisioner has own set of these parameters - }; +export interface TopologySelectorLabelRequirement { + key: string; + values: string[]; } -export class StorageClass extends KubeObject { - static kind = "StorageClass"; - static namespaced = false; - static apiBase = "/apis/storage.k8s.io/v1/storageclasses"; +export interface TopologySelectorTerm { + matchLabelExpressions?: TopologySelectorLabelRequirement[]; +} - constructor(data: KubeJsonApiData) { - super(data); +export interface StorageClassData extends KubeJsonApiData, void, void> { + allowVolumeExpansion?: boolean; + allowedTopologies?: TopologySelectorTerm[]; + mountOptions?: string[]; + parameters?: Partial>; + provisioner: string; + reclaimPolicy?: string; + volumeBindingMode?: string; +} + +export class StorageClass extends KubeObject { + static readonly kind = "StorageClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/storage.k8s.io/v1/storageclasses"; + + allowVolumeExpansion?: boolean; + allowedTopologies: TopologySelectorTerm[]; + mountOptions: string[]; + parameters: Partial>; + provisioner: string; + reclaimPolicy: string; + volumeBindingMode?: string; + + constructor({ + allowVolumeExpansion, + allowedTopologies = [], + mountOptions = [], + parameters = {}, + provisioner, + reclaimPolicy = "Delete", + volumeBindingMode, + ...rest + }: StorageClassData) { + super(rest); autoBind(this); + this.allowVolumeExpansion = allowVolumeExpansion; + this.allowedTopologies = allowedTopologies; + this.mountOptions = mountOptions; + this.parameters = parameters; + this.provisioner = provisioner; + this.reclaimPolicy = reclaimPolicy; + this.volumeBindingMode = volumeBindingMode; } isDefault() { @@ -47,14 +81,11 @@ export class StorageClass extends KubeObject { } } -let storageClassApi: KubeApi; - -if (isClusterPageContext()) { - storageClassApi = new KubeApi({ - objectConstructor: StorageClass, - }); +export class StorageClassApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: StorageClass, + }); + } } - -export { - storageClassApi, -}; diff --git a/src/common/utils/cloneJson.ts b/src/common/k8s-api/endpoints/types/aggregation-rule.ts similarity index 53% rename from src/common/utils/cloneJson.ts rename to src/common/k8s-api/endpoints/types/aggregation-rule.ts index da29062b43..57ebe32264 100644 --- a/src/common/utils/cloneJson.ts +++ b/src/common/k8s-api/endpoints/types/aggregation-rule.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -// Clone json-serializable object +import type { LabelSelector } from "../../kube-object"; -export function cloneJsonObject(obj: T): T { - return JSON.parse(JSON.stringify(obj)); +export interface AggregationRule { + clusterRoleSelectors?: LabelSelector; } diff --git a/src/common/k8s-api/endpoints/types/external-documentation.ts b/src/common/k8s-api/endpoints/types/external-documentation.ts new file mode 100644 index 0000000000..b433785323 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/external-documentation.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ExternalDocumentation { + description?: string; + url?: string; +} diff --git a/src/common/k8s-api/endpoints/types/job-template-spec.ts b/src/common/k8s-api/endpoints/types/job-template-spec.ts new file mode 100644 index 0000000000..b3ae5cceca --- /dev/null +++ b/src/common/k8s-api/endpoints/types/job-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, KubeTemplateObjectMetadata } from "../../kube-object"; +import type { JobSpec } from "../job.api"; + +export interface JobTemplateSpec { + metadata?: KubeTemplateObjectMetadata; + spec?: JobSpec; +} diff --git a/src/common/k8s-api/endpoints/types/json-schema-props.ts b/src/common/k8s-api/endpoints/types/json-schema-props.ts new file mode 100644 index 0000000000..1c0f18a7d2 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/json-schema-props.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { JsonValue } from "type-fest"; +import type { ExternalDocumentation } from "./external-documentation"; + +export interface JSONSchemaProps { + $ref?: string; + $schema?: string; + additionalItems?: JSONSchemaProps | boolean; + additionalProperties?: JSONSchemaProps | boolean; + allOf?: JSONSchemaProps[]; + anyOf?: JSONSchemaProps[]; + + /** + * default is a default value for undefined object fields. + * Defaulting is a beta feature under the CustomResourceDefaulting feature gate. + * Defaulting requires spec.preserveUnknownFields to be false. + */ + _default?: object; + + definitions?: Partial>; + dependencies?: Partial>; + description?: string; + _enum?: object[]; + example?: JsonValue; + + exclusiveMaximum?: boolean; + exclusiveMinimum?: boolean; + externalDocs?: ExternalDocumentation; + + /** + * format is an OpenAPI v3 format string. + * Unknown formats are ignored. + * + * The following formats are validated: + * - bsonobjectid: a bson object ID, i.e. a 24 characters hex string + * - uri: an URI as parsed by Golang net/url.ParseRequestURI + * - email: an email address as parsed by Golang net/mail.ParseAddress + * - hostname: a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034]. + * - ipv4: an IPv4 IP as parsed by Golang net.ParseIP + * - ipv6: an IPv6 IP as parsed by Golang net.ParseIP + * - cidr: a CIDR as parsed by Golang net.ParseCIDR + * - mac: a MAC address as parsed by Golang net.ParseMAC + * - uuid: an UUID that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + * - uuid3: an UUID3 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + * - uuid4: an UUID4 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + * - uuid5: an UUID5 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + * - isbn: an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041" + * - isbn10: an ISBN10 number string like "0321751043" + * - isbn13: an ISBN13 number string like "978-0321751041" + * - creditcard: a credit card number defined by the regex ^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$ with any non digit characters mixed in + * - ssn: a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$ + * - hexcolor: an hexadecimal color code like "#FFFFFF: following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ + * - rgbcolor: an RGB color code like rgb like "rgb(255,255,2559" + * - byte: base64 encoded binary data + * - password: any kind of string + * - date: a date string like "2006-01-02" as defined by full-date in RFC3339 + * - duration: a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format + * - datetime: a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339. + */ + format?: string; + + id?: string; + items?: JSONSchemaProps | JSONSchemaProps[]; + maxItems?: number; + maxLength?: number; + maxProperties?: number; + maximum?: number; + minItems?: number; + minLength?: number; + minProperties?: number; + minimum?: number; + multipleOf?: number; + not?: JSONSchemaProps; + nullable?: boolean; + oneOf?: JSONSchemaProps[]; + pattern?: string; + patternProperties?: Partial>; + properties?: Partial>; + required?: Array; + title?: string; + type?: string; + uniqueItems?: boolean; + x_kubernetes_embedded_resource?: boolean; + x_kubernetes_int_or_string?: boolean; + x_kubernetes_list_map_keys?: string[]; + x_kubernetes_list_type?: string; + x_kubernetes_map_type?: string; + x_kubernetes_preserve_unknown_fields?: boolean; +} diff --git a/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts b/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts new file mode 100644 index 0000000000..5461c81807 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, KubeTemplateObjectMetadata } from "../../kube-object"; +import type { PersistentVolumeSpec } from "../persistent-volume.api"; + +export interface PersistentVolumeClaimTemplateSpec { + metadata?: KubeTemplateObjectMetadata; + spec?: PersistentVolumeSpec; +} diff --git a/src/common/k8s-api/endpoints/types/pod-template-spec.ts b/src/common/k8s-api/endpoints/types/pod-template-spec.ts new file mode 100644 index 0000000000..ba0df4aec7 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/pod-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, KubeTemplateObjectMetadata } from "../../kube-object"; +import type { PodSpec } from "../pod.api"; + +export interface PodTemplateSpec { + metadata?: KubeTemplateObjectMetadata; + spec?: PodSpec; +} diff --git a/src/common/k8s-api/endpoints/types/policy-rule.ts b/src/common/k8s-api/endpoints/types/policy-rule.ts new file mode 100644 index 0000000000..b84e556223 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/policy-rule.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface PolicyRule { + verbs: string[]; + apiGroups?: string[]; + resources?: string[]; + resourceNames?: string[]; + nonResourceURLs?: string[]; +} diff --git a/src/common/k8s-api/endpoints/types/resource-requirements.ts b/src/common/k8s-api/endpoints/types/resource-requirements.ts new file mode 100644 index 0000000000..eb3b6caffa --- /dev/null +++ b/src/common/k8s-api/endpoints/types/resource-requirements.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * ResourceRequirements describes the compute resource requirements. + */ +export interface ResourceRequirements { + /** + * Limits describes the maximum amount of compute resources allowed. + * + * More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + */ + limits?: Partial>; + + /** + * Requests describes the minimum amount of compute resources required. If Requests is omitted + * for a container, it defaults to Limits if that is explicitly specified, otherwise to an + * implementation-defined value. + * + * More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + */ + requests?: Partial>; +} diff --git a/src/common/ipc-channel/channel.ts b/src/common/k8s-api/endpoints/types/role-ref.ts similarity index 72% rename from src/common/ipc-channel/channel.ts rename to src/common/k8s-api/endpoints/types/role-ref.ts index 2153134fff..60e9bdda77 100644 --- a/src/common/ipc-channel/channel.ts +++ b/src/common/k8s-api/endpoints/types/role-ref.ts @@ -2,7 +2,9 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -export interface Channel { + +export interface RoleRef { + apiGroup: string; + kind: string; name: string; - _template: TInstance; } diff --git a/src/common/k8s-api/endpoints/types/subject.ts b/src/common/k8s-api/endpoints/types/subject.ts new file mode 100644 index 0000000000..ba4e37f1a4 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/subject.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type SubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface Subject { + apiGroup?: string; + kind: SubjectKind; + name: string; + namespace?: string; +} diff --git a/src/common/k8s-api/json-api.ts b/src/common/k8s-api/json-api.ts index 41180df078..36b4fdd9f5 100644 --- a/src/common/k8s-api/json-api.ts +++ b/src/common/k8s-api/json-api.ts @@ -11,8 +11,12 @@ import { merge } from "lodash"; import type { Response, RequestInit } from "node-fetch"; import fetch from "node-fetch"; import { stringify } from "querystring"; +import type { Patch } from "rfc6902"; +import type { PartialDeep, ValueOf } from "type-fest"; import { EventEmitter } from "../../common/event-emitter"; import logger from "../../common/logger"; +import type { Defaulted } from "../utils"; +import { json } from "../utils"; export interface JsonApiData {} @@ -22,9 +26,8 @@ export interface JsonApiError { errors?: { id: string; title: string; status?: number }[]; } -export interface JsonApiParams { - query?: { [param: string]: string | number | any }; - data?: D; // request body +export interface JsonApiParams { + data?: PartialDeep; // request body } export interface JsonApiLog { @@ -35,63 +38,69 @@ export interface JsonApiLog { error?: any; } +export type GetRequestOptions = () => Promise; + export interface JsonApiConfig { apiBase: string; serverAddress: string; debug?: boolean; - getRequestOptions?: () => Promise; + getRequestOptions?: GetRequestOptions; } const httpAgent = new HttpAgent({ keepAlive: true }); const httpsAgent = new HttpsAgent({ keepAlive: true }); -export class JsonApi { - static reqInitDefault: RequestInit = { +export type QueryParam = string | number | boolean | null | undefined | readonly string[] | readonly number[] | readonly boolean[]; +export type QueryParams = Partial>; + +export type ParamsAndQuery = ( + ValueOf extends QueryParam + ? Params & { query?: Query } + : Params & { query?: undefined } +); + +export class JsonApi = JsonApiParams> { + static readonly reqInitDefault = { headers: { "content-type": "application/json", }, }; + protected readonly reqInit: Defaulted; - static configDefault: Partial = { + static readonly configDefault: Partial = { debug: false, }; - constructor(public readonly config: JsonApiConfig, protected reqInit?: RequestInit) { + constructor(public readonly config: JsonApiConfig, reqInit?: RequestInit) { this.config = Object.assign({}, JsonApi.configDefault, config); this.reqInit = merge({}, JsonApi.reqInitDefault, reqInit); this.parseResponse = this.parseResponse.bind(this); this.getRequestOptions = config.getRequestOptions ?? (() => Promise.resolve({})); } - public onData = new EventEmitter<[D, Response]>(); - public onError = new EventEmitter<[JsonApiErrorParsed, Response]>(); + public readonly onData = new EventEmitter<[Data, Response]>(); + public readonly onError = new EventEmitter<[JsonApiErrorParsed, Response]>(); + private readonly getRequestOptions: GetRequestOptions; - private getRequestOptions: JsonApiConfig["getRequestOptions"]; - - get(path: string, params?: P, reqInit: RequestInit = {}) { - return this.request(path, params, { ...reqInit, method: "get" }); - } - - async getResponse(path: string, params?: P, init: RequestInit = {}): Promise { + async getResponse( + path: string, + params?: ParamsAndQuery, + init: RequestInit = {}, + ): Promise { let reqUrl = `${this.config.serverAddress}${this.config.apiBase}${path}`; - const reqInit: RequestInit = merge( - {}, + const reqInit = merge( + { + method: "get", + agent: reqUrl.startsWith("https:") ? httpsAgent : httpAgent, + }, this.reqInit, await this.getRequestOptions(), init, ); - const { query } = params || {} as P; - - if (!reqInit.method) { - reqInit.method = "get"; - } - - if (!reqInit.agent) { - reqInit.agent = reqUrl.startsWith("https:") ? httpsAgent : httpAgent; - } + const { query } = params ?? {}; if (query) { - const queryString = stringify(query); + const queryString = stringify(query as unknown as QueryParams); reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } @@ -99,38 +108,66 @@ export class JsonApi { return fetch(reqUrl, reqInit); } - post(path: string, params?: P, reqInit: RequestInit = {}) { - return this.request(path, params, { ...reqInit, method: "post" }); + get( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "get" }); } - put(path: string, params?: P, reqInit: RequestInit = {}) { - return this.request(path, params, { ...reqInit, method: "put" }); + post( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "post" }); } - patch(path: string, params?: P, reqInit: RequestInit = {}) { - return this.request(path, params, { ...reqInit, method: "PATCH" }); + put( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "put" }); } - del(path: string, params?: P, reqInit: RequestInit = {}) { - return this.request(path, params, { ...reqInit, method: "delete" }); + patch( + path: string, + params?: (ParamsAndQuery, Query> & { data?: Patch | PartialDeep }), + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "patch" }); } - protected async request(path: string, params?: P, init: RequestInit = {}) { + del( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "delete" }); + } + + protected async request( + path: string, + params: (ParamsAndQuery, Query> & { data?: unknown }) | undefined, + init: Defaulted, + ) { let reqUrl = `${this.config.serverAddress}${this.config.apiBase}${path}`; - const reqInit: RequestInit = merge( + const reqInit = merge( {}, this.reqInit, await this.getRequestOptions(), init, ); - const { data, query } = params || {} as P; + const { data, query } = params || {}; if (data && !reqInit.body) { reqInit.body = JSON.stringify(data); } if (query) { - const queryString = stringify(query); + const queryString = stringify(query as unknown as QueryParams); reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } @@ -142,17 +179,17 @@ export class JsonApi { const res = await fetch(reqUrl, reqInit); - return this.parseResponse(res, infoLog); + return this.parseResponse(res, infoLog); } - protected async parseResponse(res: Response, log: JsonApiLog): Promise { + protected async parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; const text = await res.text(); - let data; + let data: any; try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + data = text ? json.parse(text) : ""; // DELETE-requests might not have response-body } catch (e) { data = text; } diff --git a/src/common/k8s-api/kube-api-parse.ts b/src/common/k8s-api/kube-api-parse.ts index a47f0e5a1b..509f1c28e9 100644 --- a/src/common/k8s-api/kube-api-parse.ts +++ b/src/common/k8s-api/kube-api-parse.ts @@ -6,16 +6,6 @@ // Parse kube-api path and get api-version, group, etc. import { splitArray } from "../utils"; -import { isDebugging } from "../vars"; -import logger from "../../main/logger"; -import { inspect } from "util"; - -export interface IKubeObjectRef { - kind: string; - apiVersion: string; - name: string; - namespace?: string; -} export interface IKubeApiLinkRef { apiPrefix?: string; @@ -32,35 +22,21 @@ export interface IKubeApiParsed extends IKubeApiLinkRef { } export function parseKubeApi(path: string): IKubeApiParsed { - if (!isDebugging) { - return _parseKubeApi(path); - } - - try { - const res = _parseKubeApi(path); - - logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) -> ${inspect(res, false, null, false)}`); - - return res; - } catch (error) { - logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) threw: ${error}`); - - throw error; - } -} - -function _parseKubeApi(path: string): IKubeApiParsed { const apiPath = new URL(path, "http://localhost").pathname; const [, prefix, ...parts] = apiPath.split("/"); const apiPrefix = `/${prefix}`; const [left, right, namespaced] = splitArray(parts, "namespaces"); - let apiGroup, apiVersion, namespace, resource, name; + let apiGroup!: string; + let apiVersion!: string; + let namespace!: string; + let resource!: string; + let name!: string; if (namespaced) { switch (right.length) { case 1: name = right[0]; - // fallthrough + // fallthrough case 0: resource = "namespaces"; // special case this due to `split` removing namespaces break; @@ -69,7 +45,8 @@ function _parseKubeApi(path: string): IKubeApiParsed { break; } - apiVersion = left.pop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + apiVersion = left.pop()!; apiGroup = left.join("/"); } else { switch (left.length) { @@ -79,10 +56,12 @@ function _parseKubeApi(path: string): IKubeApiParsed { [apiGroup, apiVersion, resource, name] = left; break; case 2: - resource = left.pop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resource = left.pop()!; // fallthrough case 1: - apiVersion = left.pop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + apiVersion = left.pop()!; apiGroup = ""; break; default: diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 8d890a1d75..4a487b82d3 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -8,34 +8,58 @@ import { isFunction, merge } from "lodash"; import { stringify } from "querystring"; import { apiKubePrefix, isDevelopment } from "../../common/vars"; -import logger from "../../main/logger"; -import { apiManager } from "./api-manager"; import { apiBase, apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import type { KubeObjectConstructor } from "./kube-object"; -import { KubeObject, KubeStatus } from "./kube-object"; +import type { KubeObjectConstructor, KubeJsonApiDataFor, KubeObjectMetadata, KubeObjectScope } from "./kube-object"; +import { KubeObject, KubeStatus, isKubeStatusData } from "./kube-object"; import byline from "byline"; import type { IKubeWatchEvent } from "./kube-watch-event"; import type { KubeJsonApiData } from "./kube-json-api"; import { KubeJsonApi } from "./kube-json-api"; -import { noop, WrappedAbortController } from "../utils"; +import type { Disposer } from "../utils"; +import { isDefined, noop, WrappedAbortController } from "../utils"; import type { RequestInit } from "node-fetch"; import type AbortController from "abort-controller"; import type { AgentOptions } from "https"; import { Agent } from "https"; import type { Patch } from "rfc6902"; +import assert from "assert"; +import type { PartialDeep } from "type-fest"; +import logger from "../logger"; +import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import autoRegistrationEmitterInjectable from "./api-manager/auto-registration-emitter.injectable"; /** * The options used for creating a `KubeApi` */ -export interface IKubeApiOptions { +export interface KubeApiOptions, Data extends KubeJsonApiDataFor = KubeJsonApiDataFor> extends DerivedKubeApiOptions { /** * base api-path for listing all resources, e.g. "/api/v1/pods" * - * If not specified then will be the one on the `objectConstructor` + * Must be provided either here or under `objectConstructor.apiBase` + * @deprecated should be specified by `objectConstructor` */ apiBase?: string; + /** + * The constructor for the kube objects returned from the API + */ + objectConstructor: KubeObjectConstructor; + + /** + * Must be provided either here or under `objectConstructor.namespaced` + * @deprecated should be specified by `objectConstructor` + */ + isNamespaced?: boolean; + + /** + * Must be provided either here or under `objectConstructor.kind` + * @deprecated should be specified by `objectConstructor` + */ + kind?: string; +} + +export interface DerivedKubeApiOptions { /** * If the API uses a different API endpoint (e.g. apiBase) depending on the cluster version, * fallback API bases can be listed individually. @@ -50,30 +74,37 @@ export interface IKubeApiOptions { */ checkPreferredVersion?: boolean; - /** - * The constructor for the kube objects returned from the API - */ - objectConstructor: KubeObjectConstructor; - /** * The api instance to use for making requests * * @default apiKube */ request?: KubeJsonApi; - - /** - * @deprecated should be specified by `objectConstructor` - */ - isNamespaced?: boolean; - - /** - * @deprecated should be specified by `objectConstructor` - */ - kind?: string; } -export interface IKubeApiQueryParams { +/** + * @deprecated This type is only present for backwards compatable typescript support + */ +export interface IgnoredKubeApiOptions { + /** + * @deprecated this option is overridden and should not be used + */ + objectConstructor?: any; + /** + * @deprecated this option is overridden and should not be used + */ + kind?: any; + /** + * @deprecated this option is overridden and should not be used + */ + isNamespaces?: any; + /** + * @deprecated this option is overridden and should not be used + */ + apiBase?: any; +} + +export interface KubeApiQueryParams { watch?: boolean | number; resourceVersion?: string; timeoutSeconds?: number; @@ -94,15 +125,24 @@ export interface IKubePreferredVersion { }; } -export interface IKubeResourceList { - resources: { - kind: string; - name: string; - namespaced: boolean; - singularName: string; - storageVersionHash: string; - verbs: string[]; - }[]; +export interface KubeApiResource { + categories?: string[]; + group?: string; + kind: string; + name: string; + namespaced: boolean; + shortNames?: string[]; + singularName: string; + storageVersionHash?: string; + verbs: string[]; + version?: string; +} + +export interface KubeApiResourceList { + apiVersion?: string; + groupVersion?: string; + kind?: string; + resources: KubeApiResource[]; } export interface ILocalKubeApiConfig { @@ -118,10 +158,6 @@ export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Backgroun */ export interface IKubeApiCluster extends ILocalKubeApiConfig { } -export type PartialKubeObject = Partial> & { - metadata?: Partial; -}; - export interface IRemoteKubeApiConfig { cluster: { server: string; @@ -133,9 +169,29 @@ export interface IRemoteKubeApiConfig { clientCertificateData?: string; clientKeyData?: string; }; + /** + * Custom instance of https.agent to use for the requests + * + * @remarks the custom agent replaced default agent, options skipTLSVerify, + * clientCertificateData, clientKeyData and caData are ignored. + */ + agent?: Agent; } -export function forCluster = KubeApi>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): KubeApi { +export function forCluster< + Object extends KubeObject, + Api extends KubeApi, + Data extends KubeJsonApiDataFor, +>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: KubeApiOptions) => Api): Api; +export function forCluster< + Object extends KubeObject, + Data extends KubeJsonApiDataFor, +>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass?: new (apiOpts: KubeApiOptions) => KubeApi): KubeApi; + +export function forCluster< + Object extends KubeObject, + Data extends KubeJsonApiDataFor, +>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: (new (apiOpts: KubeApiOptions) => KubeApi) = KubeApi): KubeApi { const url = new URL(apiBase.config.serverAddress); const request = new KubeJsonApi({ serverAddress: apiBase.config.serverAddress, @@ -147,17 +203,27 @@ export function forCluster = KubeApi< }, }); - if (!apiClass) { - apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; - } - return new apiClass({ - objectConstructor: kubeClass, + objectConstructor: kubeClass as KubeObjectConstructor>, request, }); } -export function forRemoteCluster = KubeApi>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): Y { +export function forRemoteCluster< + Object extends KubeObject, + Api extends KubeApi, + Data extends KubeJsonApiDataFor, +>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: KubeApiOptions) => Api): Api; +export function forRemoteCluster< + Object extends KubeObject, + Data extends KubeJsonApiDataFor, +>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass?: new (apiOpts: KubeApiOptions) => KubeApi): KubeApi; + +export function forRemoteCluster< + Object extends KubeObject, + Api extends KubeApi, + Data extends KubeJsonApiDataFor, +>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: KubeApiOptions) => KubeApi = KubeApi): KubeApi { const reqInit: RequestInit = {}; const agentOptions: AgentOptions = {}; @@ -181,6 +247,10 @@ export function forRemoteCluster = Ku reqInit.agent = new Agent(agentOptions); } + if (config.agent) { + reqInit.agent = config.agent; + } + const token = config.user.token; const request = new KubeJsonApi({ serverAddress: config.cluster.server, @@ -196,32 +266,20 @@ export function forRemoteCluster = Ku }, reqInit); if (!apiClass) { - apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; + apiClass = KubeApi as new (apiOpts: KubeApiOptions) => Api; } return new apiClass({ - objectConstructor: kubeClass as KubeObjectConstructor, + objectConstructor: kubeClass as KubeObjectConstructor>, request, }); } -export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { - if (!object.metadata.selfLink) { - object.metadata.selfLink = createKubeApiURL({ - apiPrefix: api.apiPrefix, - apiVersion: api.apiVersionWithGroup, - resource: api.apiResource, - namespace: api.isNamespaced ? object.metadata.namespace : undefined, - name: object.metadata.name, - }); - } -} +export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; -export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; - -export interface KubeApiWatchOptions { +export interface KubeApiWatchOptions, Data extends KubeJsonApiDataFor> { namespace: string; - callback?: KubeApiWatchCallback; + callback?: KubeApiWatchCallback; abortController?: AbortController; watchId?: string; retry?: boolean; @@ -261,39 +319,84 @@ export interface DeleteResourceDescriptor extends ResourceDescriptor { propagationPolicy?: PropagationPolicy; } -export class KubeApi { +/** + * @deprecated In the new extension API, don't expose `KubeApi`'s constructor + */ +function legacyRegisterApi(api: KubeApi): void { + try { + /** + * This function throws if called in `main`, so the `try..catch` is to make sure that doesn't + * leak. + * + * However, we need this code to be run in `renderer` so that the auto registering of `KubeApi` + * instances still works. That auto registering never worked or was applicable in `main` because + * there is no "single cluster" on `main`. + * + * TODO: rearchitect this design pattern in the new extension API + */ + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.renderer); + const autoRegistrationEmitter = di.inject(autoRegistrationEmitterInjectable); + + autoRegistrationEmitter.emit("kubeApi", api); + } catch { + // ignore error + } +} + +export class KubeApi< + Object extends KubeObject = KubeObject, + Data extends KubeJsonApiDataFor = KubeJsonApiDataFor, +> { readonly kind: string; readonly apiVersion: string; apiBase: string; apiPrefix: string; apiGroup: string; - apiVersionPreferred?: string; + apiVersionPreferred: string | undefined; readonly apiResource: string; readonly isNamespaced: boolean; - public objectConstructor: KubeObjectConstructor; - protected request: KubeJsonApi; - protected resourceVersions = new Map(); - protected watchDisposer: () => void; + public readonly objectConstructor: KubeObjectConstructor; + protected readonly request: KubeJsonApi; + protected readonly resourceVersions = new Map(); + protected readonly watchDisposer: Disposer | undefined; private watchId = 1; + protected readonly doCheckPreferredVersion: boolean; + protected readonly fullApiPathname: string; + protected readonly fallbackApiBases: string[] | undefined; - constructor(protected options: IKubeApiOptions) { - const { objectConstructor, request, kind, isNamespaced } = options; - const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase); + constructor(opts: KubeApiOptions) { + const { + objectConstructor, + request = apiKube, + kind = objectConstructor.kind, + isNamespaced, + apiBase: fullApiPathname = objectConstructor.apiBase, + checkPreferredVersion: doCheckPreferredVersion = false, + fallbackApiBases, + } = opts; - this.options = options; - this.kind = kind ?? objectConstructor.kind; + assert(fullApiPathname, "apiBase MUST be provied either via KubeApiOptions.apiBase or KubeApiOptions.objectConstructor.apiBase"); + assert(request, "request MUST be provided if not in a cluster page frame context"); + + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(fullApiPathname); + + assert(kind, "kind MUST be provied either via KubeApiOptions.kind or KubeApiOptions.objectConstructor.kind"); + assert(apiPrefix, "apiBase MUST be parsable as a kubeApi selfLink style string"); + + this.doCheckPreferredVersion = doCheckPreferredVersion; + this.fallbackApiBases = fallbackApiBases; + this.fullApiPathname = fullApiPathname; + this.kind = kind; this.isNamespaced = isNamespaced ?? objectConstructor.namespaced ?? false; this.apiBase = apiBase; this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; this.apiVersion = apiVersion; this.apiResource = resource; - this.request = request ?? apiKube; + this.request = request; this.objectConstructor = objectConstructor; - - this.parseResponse = this.parseResponse.bind(this); - apiManager.registerApi(apiBase, this); + legacyRegisterApi(this); } get apiVersionWithGroup() { @@ -304,11 +407,11 @@ export class KubeApi { /** * Returns the latest API prefix/group that contains the required resource. - * First tries options.apiBase, then urls in order from options.fallbackApiBases. + * First tries fullApiPathname, then urls in order from fallbackApiBases. */ private async getLatestApiPrefixGroup() { - // Note that this.options.apiBase is the "full" url, whereas this.apiBase is parsed - const apiBases = [this.options.apiBase, this.objectConstructor.apiBase, ...this.options.fallbackApiBases]; + // Note that this.fullApiPathname is the "full" url, whereas this.apiBase is parsed + const apiBases = [this.fullApiPathname, this.objectConstructor.apiBase, ...this.fallbackApiBases ?? []]; for (const apiUrl of apiBases) { if (!apiUrl) { @@ -320,10 +423,10 @@ export class KubeApi { const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl); // Request available resources - const response = await this.request.get(`${apiPrefix}/${apiVersionWithGroup}`); + const { resources } = (await this.request.get(`${apiPrefix}/${apiVersionWithGroup}`)) as unknown as KubeApiResourceList; // If the resource is found in the group, use this apiUrl - if (response.resources?.find(kubeResource => kubeResource.name === resource)) { + if (resources.find(({ name }) => name === resource)) { return { apiPrefix, apiGroup }; } } catch (error) { @@ -338,7 +441,7 @@ export class KubeApi { * Get the apiPrefix and apiGroup to be used for fetching the preferred version. */ private async getPreferredVersionPrefixGroup() { - if (this.options.fallbackApiBases) { + if (this.fallbackApiBases) { try { return await this.getLatestApiPrefixGroup(); } catch (error) { @@ -354,25 +457,27 @@ export class KubeApi { } protected async checkPreferredVersion() { - if (this.options.fallbackApiBases && !this.options.checkPreferredVersion) { + if (this.fallbackApiBases && !this.doCheckPreferredVersion) { throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); } - if (this.options.checkPreferredVersion && this.apiVersionPreferred === undefined) { + if (this.doCheckPreferredVersion && this.apiVersionPreferred === undefined) { const { apiPrefix, apiGroup } = await this.getPreferredVersionPrefixGroup(); + assert(apiPrefix); + // The apiPrefix and apiGroup might change due to fallbackApiBases, so we must override them this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; const url = [apiPrefix, apiGroup].filter(Boolean).join("/"); - const res = await this.request.get(url); + const res = await this.request.get(url) as IKubePreferredVersion; - this.apiVersionPreferred = res?.preferredVersion?.version ?? null; + this.apiVersionPreferred = res?.preferredVersion?.version; if (this.apiVersionPreferred) { this.apiBase = this.computeApiBase(); - apiManager.registerApi(this.apiBase, this); + legacyRegisterApi(this); } } } @@ -397,7 +502,7 @@ export class KubeApi { }); } - getUrl({ name, namespace }: Partial = {}, query?: Partial) { + getUrl({ name, namespace }: Partial = {}, query?: Partial) { const resourcePath = createKubeApiURL({ apiPrefix: this.apiPrefix, apiVersion: this.apiVersionWithGroup, @@ -409,7 +514,7 @@ export class KubeApi { return resourcePath + (query ? `?${stringify(this.normalizeQuery(query))}` : ""); } - protected normalizeQuery(query: Partial = {}) { + protected normalizeQuery(query: Partial = {}) { if (query.labelSelector) { query.labelSelector = [query.labelSelector].flat().join(","); } @@ -421,8 +526,11 @@ export class KubeApi { return query; } - protected parseResponse(data: unknown, namespace?: string): T | T[] | null { - if (!data) return null; + protected parseResponse(data: unknown, namespace?: string): Object | Object[] | null { + if (!data) { + return null; + } + const KubeObjectConstructor = this.objectConstructor; // process items list response, check before single item since there is overlap @@ -432,37 +540,55 @@ export class KubeApi { this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion); - return items.map((item) => { - const object = new KubeObjectConstructor({ - kind: this.kind, - apiVersion, - ...item, - }); + return items + .map((item) => { + if (item.metadata) { + this.ensureMetadataSelfLink(item.metadata); + } else { + return undefined; + } - ensureObjectSelfLink(this, object); + const object = new KubeObjectConstructor({ + ...(item as Data), + kind: this.kind, + apiVersion, + }); - return object; - }); + return object; + }) + .filter(isDefined); } // process a single item if (KubeObject.isJsonApiData(data)) { - const object = new KubeObjectConstructor(data); + this.ensureMetadataSelfLink(data.metadata); - ensureObjectSelfLink(this, object); - - return object; + return new KubeObjectConstructor(data as never); } // custom apis might return array for list response, e.g. users, groups, etc. if (Array.isArray(data)) { - return data.map(data => new KubeObjectConstructor(data)); + return data.map(data => { + this.ensureMetadataSelfLink(data.metadata); + + return new KubeObjectConstructor(data); + }); } return null; } - async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { + private ensureMetadataSelfLink(metadata: T): asserts metadata is T & { selfLink: string } { + metadata.selfLink ||= createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: metadata.namespace, + name: metadata.name, + }); + } + + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); const url = this.getUrl({ namespace }); @@ -480,7 +606,7 @@ export class KubeApi { throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); } - async get(desc: ResourceDescriptor, query?: IKubeApiQueryParams): Promise { + async get(desc: ResourceDescriptor, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); const url = this.getUrl(desc); @@ -494,20 +620,19 @@ export class KubeApi { return parsed; } - async create({ name, namespace }: Partial, data?: PartialKubeObject): Promise { + async create({ name, namespace }: Partial, partialData?: PartialDeep): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace }); - const res = await this.request.post(apiUrl, { - data: merge(data, { - kind: this.kind, - apiVersion: this.apiVersionWithGroup, - metadata: { - name, - namespace, - }, - }), + const data = merge(partialData, { + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace, + }, }); + const res = await this.request.post(apiUrl, { data }); const parsed = this.parseResponse(res); if (Array.isArray(parsed)) { @@ -517,7 +642,7 @@ export class KubeApi { return parsed; } - async update({ name, namespace }: ResourceDescriptor, data: PartialKubeObject): Promise { + async update({ name, namespace }: ResourceDescriptor, data: PartialDeep): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); @@ -538,7 +663,7 @@ export class KubeApi { return parsed; } - async patch(desc: ResourceDescriptor, data?: PartialKubeObject | Patch, strategy: KubeApiPatchType = "strategic") { + async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType = "strategic"): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl(desc); @@ -553,7 +678,7 @@ export class KubeApi { throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); } - return parsed as T | null; + return parsed; } async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) { @@ -567,7 +692,7 @@ export class KubeApi { }); } - getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) { + getWatchUrl(namespace = "", query: KubeApiQueryParams = {}) { return this.getUrl({ namespace }, { watch: 1, resourceVersion: this.getResourceVersion(namespace), @@ -575,7 +700,7 @@ export class KubeApi { }); } - watch(opts: KubeApiWatchOptions = { namespace: "", retry: false }): () => void { + watch(opts: KubeApiWatchOptions = { namespace: "", retry: false }): () => void { let errorReceived = false; let timedRetry: NodeJS.Timeout; const { namespace, callback = noop, retry, timeout } = opts; @@ -653,9 +778,9 @@ export class KubeApi { byline(response.body).on("data", (line) => { try { - const event: IKubeWatchEvent = JSON.parse(line); + const event = JSON.parse(line) as IKubeWatchEvent>; - if (event.type === "ERROR" && event.object.kind === "Status") { + if (event.type === "ERROR" && isKubeStatusData(event.object)) { errorReceived = true; return callback(null, new KubeStatus(event.object)); @@ -679,15 +804,17 @@ export class KubeApi { }; } - protected modifyWatchEvent(event: IKubeWatchEvent) { + protected modifyWatchEvent(event: IKubeWatchEvent>) { if (event.type === "ERROR") { return; } - ensureObjectSelfLink(this, event.object); + this.ensureMetadataSelfLink(event.object.metadata); const { namespace, resourceVersion } = event.object.metadata; + assert(resourceVersion, "watch events failed to return resourceVersion from kube api"); + this.setResourceVersion(namespace, resourceVersion); this.setResourceVersion("", resourceVersion); } diff --git a/src/common/k8s-api/kube-json-api.ts b/src/common/k8s-api/kube-json-api.ts index 258c61af5e..2624f030c8 100644 --- a/src/common/k8s-api/kube-json-api.ts +++ b/src/common/k8s-api/kube-json-api.ts @@ -8,6 +8,7 @@ import { JsonApi } from "./json-api"; import type { Response } from "node-fetch"; import { apiKubePrefix, isDebugging } from "../vars"; import { apiBase } from "./api-base"; +import type { KubeJsonApiObjectMetadata } from "./kube-object"; export interface KubeJsonApiListMetadata { resourceVersion: string; @@ -21,28 +22,17 @@ export interface KubeJsonApiDataList { metadata: KubeJsonApiListMetadata; } -export interface KubeJsonApiMetadata { - uid: string; - name: string; - namespace?: string; - creationTimestamp?: string; - resourceVersion: string; - continue?: string; - finalizers?: string[]; - selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - [key: string]: any; -} - -export interface KubeJsonApiData extends JsonApiData { +export interface KubeJsonApiData< + Metadata extends KubeJsonApiObjectMetadata = KubeJsonApiObjectMetadata, + Status = unknown, + Spec = unknown, +> extends JsonApiData { kind: string; apiVersion: string; - metadata: KubeJsonApiMetadata; + metadata: Metadata; + status?: Status; + spec?: Spec; + [otherKeys: string]: unknown; } export interface KubeJsonApiError extends JsonApiError { @@ -71,7 +61,11 @@ export class KubeJsonApi extends JsonApi { }); } - protected parseError(error: KubeJsonApiError | any, res: Response): string[] { + protected parseError(error: KubeJsonApiError | string, res: Response): string[] { + if (typeof error === "string") { + return [error]; + } + const { status, reason, message } = error; if (status && reason) { diff --git a/src/extensions/renderer-api/kube-object-status.ts b/src/common/k8s-api/kube-object-status.ts similarity index 100% rename from src/extensions/renderer-api/kube-object-status.ts rename to src/common/k8s-api/kube-object-status.ts diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index c7780585e8..ca4e1d9000 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -7,22 +7,22 @@ import type { ClusterContext } from "./cluster-context"; import { action, computed, makeObservable, observable, reaction, when } from "mobx"; import type { Disposer } from "../utils"; -import { autoBind, noop, rejectPromiseBy } from "../utils"; -import type { KubeObject } from "./kube-object"; +import { autoBind, includes, isRequestError, noop, rejectPromiseBy } from "../utils"; +import type { KubeJsonApiDataFor, KubeObject } from "./kube-object"; import { KubeStatus } from "./kube-object"; import type { IKubeWatchEvent } from "./kube-watch-event"; import { ItemStore } from "../item.store"; -import type { IKubeApiQueryParams, KubeApi } from "./kube-api"; -import { ensureObjectSelfLink } from "./kube-api"; +import type { KubeApiQueryParams, KubeApi, KubeApiWatchCallback } from "./kube-api"; import { parseKubeApi } from "./kube-api-parse"; -import type { KubeJsonApiData } from "./kube-json-api"; import type { RequestInit } from "node-fetch"; - -// BUG: https://github.com/mysticatea/abort-controller/pull/22 -// eslint-disable-next-line import/no-named-as-default import AbortController from "abort-controller"; import type { Patch } from "rfc6902"; import logger from "../logger"; +import assert from "assert"; +import type { PartialDeep } from "type-fest"; +import { entries } from "../utils/objects"; + +export type OnLoadFailure = (error: unknown) => void; export interface KubeObjectStoreLoadingParams { namespaces: string[]; @@ -32,7 +32,7 @@ export interface KubeObjectStoreLoadingParams { * A function that is called when listing fails. If set then blocks errors * being rejected with */ - onLoadFailure?: (err: any) => void; + onLoadFailure?: OnLoadFailure; } export interface KubeObjectStoreLoadAllParams { @@ -44,7 +44,7 @@ export interface KubeObjectStoreLoadAllParams { * A function that is called when listing fails. If set then blocks errors * being rejected with */ - onLoadFailure?: (err: any) => void; + onLoadFailure?: OnLoadFailure; } export interface KubeObjectStoreSubscribeParams { @@ -52,7 +52,7 @@ export interface KubeObjectStoreSubscribeParams { * A function that is called when listing fails. If set then blocks errors * being rejected with */ - onLoadFailure?: (err: any) => void; + onLoadFailure?: OnLoadFailure; /** * An optional parent abort controller @@ -68,13 +68,32 @@ export interface MergeItemsOptions { namespaces: string[]; } -export abstract class KubeObjectStore extends ItemStore { - static defaultContext = observable.box(); // TODO: support multiple cluster contexts +export interface StatusProvider { + getStatuses(items: K[]): Record; +} - public readonly api: KubeApi; - public readonly limit?: number; - public readonly bufferSize: number = 50000; - @observable private loadedNamespaces?: string[]; +export interface KubeObjectStoreOptions { + limit?: number; + bufferSize?: number; +} + +export type KubeApiDataFrom = A extends KubeApi + ? D extends KubeJsonApiDataFor + ? D + : never + : never; + +export abstract class KubeObjectStore< + K extends KubeObject = KubeObject, + A extends KubeApi = KubeApi>, + D extends KubeJsonApiDataFor = KubeApiDataFrom, +> extends ItemStore { + static readonly defaultContext = observable.box(); // TODO: support multiple cluster contexts + + public readonly api: A; + public readonly limit: number | undefined; + public readonly bufferSize: number; + @observable private loadedNamespaces: string[] | undefined = undefined; get contextReady() { return when(() => Boolean(this.context)); @@ -84,9 +103,11 @@ export abstract class KubeObjectStore extends ItemStore return when(() => Boolean(this.loadedNamespaces)); } - constructor(api?: KubeApi) { + constructor(api: A, opts?: KubeObjectStoreOptions) { super(); - if (api) this.api = api; + this.api = api; + this.limit = opts?.limit; + this.bufferSize = opts?.bufferSize ?? 50_000; makeObservable(this); autoBind(this); @@ -98,7 +119,7 @@ export abstract class KubeObjectStore extends ItemStore } // TODO: Circular dependency: KubeObjectStore -> ClusterFrameContext -> NamespaceStore -> KubeObjectStore - @computed get contextItems(): T[] { + @computed get contextItems(): K[] { const namespaces = this.context?.contextNamespaces ?? []; return this.items.filter(item => { @@ -112,7 +133,7 @@ export abstract class KubeObjectStore extends ItemStore return this.contextItems.length; } - get query(): IKubeApiQueryParams { + get query(): KubeApiQueryParams { const { limit } = this; if (!limit) { @@ -122,13 +143,11 @@ export abstract class KubeObjectStore extends ItemStore return { limit }; } - getStatuses?(items: T[]): Record; - - getAllByNs(namespace: string | string[], strict = false): T[] { - const namespaces: string[] = [].concat(namespace); + getAllByNs(namespace: string | string[], strict = false): K[] { + const namespaces = [namespace].flat(); if (namespaces.length) { - return this.items.filter(item => namespaces.includes(item.getNs())); + return this.items.filter(item => includes(namespaces, item.getNs())); } if (!strict) { @@ -138,11 +157,11 @@ export abstract class KubeObjectStore extends ItemStore return []; } - getById(id: string) { + getById(id: string): K | undefined { return this.items.find(item => item.getId() === id); } - getByName(name: string, namespace?: string): T { + getByName(name: string, namespace?: string): K | undefined { return this.items.find(item => { return item.getName() === name && ( namespace ? item.getNs() === namespace : true @@ -150,29 +169,29 @@ export abstract class KubeObjectStore extends ItemStore }); } - getByPath(path: string): T { + getByPath(path: string): K | undefined { return this.items.find(item => item.selfLink === path); } - getByLabel(labels: string[] | { [label: string]: string }): T[] { + getByLabel(labels: string[] | Partial>): K[] { if (Array.isArray(labels)) { - return this.items.filter((item: T) => { + return this.items.filter((item: K) => { const itemLabels = item.getLabels(); return labels.every(label => itemLabels.includes(label)); }); } else { - return this.items.filter((item: T) => { + return this.items.filter((item: K) => { const itemLabels = item.metadata.labels || {}; - return Object.entries(labels) + return entries(labels) .every(([key, value]) => itemLabels[key] === value); }); } } - protected async loadItems({ namespaces, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams): Promise { - if (!this.context?.cluster.isAllowedResource(this.api.kind)) { + protected async loadItems({ namespaces, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams): Promise { + if (!this.context?.cluster?.isAllowedResource(this.api.kind)) { return []; } @@ -189,9 +208,13 @@ export abstract class KubeObjectStore extends ItemStore if (onLoadFailure) { try { - return await res; + return await res ?? []; } catch (error) { - onLoadFailure(error?.message || error?.toString() || "Unknown error"); + onLoadFailure(( + isRequestError(error) + ? error.message || error.toString() + : "Unknown error" + )); // reset the store because we are loading all, so that nothing is displayed this.items.clear(); @@ -201,7 +224,7 @@ export abstract class KubeObjectStore extends ItemStore } } - return res; + return await res ?? []; } this.loadedNamespaces = namespaces; @@ -209,12 +232,12 @@ export abstract class KubeObjectStore extends ItemStore const results = await Promise.allSettled( namespaces.map(namespace => this.api.list({ namespace, reqInit }, this.query)), ); - const res: T[] = []; + const res: K[] = []; for (const result of results) { switch (result.status) { case "fulfilled": - res.push(...result.value); + res.push(...result.value ?? []); break; case "rejected": @@ -230,12 +253,12 @@ export abstract class KubeObjectStore extends ItemStore return res; } - protected filterItemsOnLoad(items: T[]) { + protected filterItemsOnLoad(items: K[]) { return items; } @action - async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { + async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { await this.contextReady; namespaces ??= this.context.contextNamespaces; this.isLoading = true; @@ -261,7 +284,7 @@ export abstract class KubeObjectStore extends ItemStore } @action - async reloadAll(opts: { force?: boolean; namespaces?: string[]; merge?: boolean } = {}): Promise { + async reloadAll(opts: { force?: boolean; namespaces?: string[]; merge?: boolean } = {}): Promise { const { force = false, ...loadingOptions } = opts; if (this.isLoading || (this.isLoaded && !force)) { @@ -272,7 +295,7 @@ export abstract class KubeObjectStore extends ItemStore } @action - protected mergeItems(partialItems: T[], { merge = true, updateStore = true, sort = true, filter = true, namespaces }: MergeItemsOptions): T[] { + protected mergeItems(partialItems: K[], { merge = true, updateStore = true, sort = true, filter = true, namespaces }: MergeItemsOptions): K[] { let items = partialItems; // update existing items @@ -280,7 +303,7 @@ export abstract class KubeObjectStore extends ItemStore const ns = new Set(namespaces); items = [ - ...this.items.filter(existingItem => !ns.has(existingItem.getNs())), + ...this.items.filter(item => !ns.has(item.getNs() as string)), ...partialItems, ]; } @@ -296,17 +319,18 @@ export abstract class KubeObjectStore extends ItemStore if (error) this.reset(); } - protected async loadItem(params: { name: string; namespace?: string }): Promise { + protected async loadItem(params: { name: string; namespace?: string }): Promise { return this.api.get(params); } @action - async load(params: { name: string; namespace?: string }): Promise { + async load(params: { name: string; namespace?: string }): Promise { const { name, namespace } = params; - let item = this.getByName(name, namespace); + let item: K | null | undefined = this.getByName(name, namespace); if (!item) { item = await this.loadItem(params); + assert(item, "Failed to load item from kube"); const newItems = this.sortItems([...this.items, item]); this.items.replace(newItems); @@ -319,15 +343,19 @@ export abstract class KubeObjectStore extends ItemStore async loadFromPath(resourcePath: string) { const { namespace, name } = parseKubeApi(resourcePath); + assert(name && namespace, "Both name and namesapce must be part of resourcePath"); + return this.load({ name, namespace }); } - protected async createItem(params: { name: string; namespace?: string }, data?: Partial): Promise { + protected async createItem(params: { name: string; namespace?: string }, data?: PartialDeep): Promise { return this.api.create(params, data); } - async create(params: { name: string; namespace?: string }, data?: Partial): Promise { + async create(params: { name: string; namespace?: string }, data?: PartialDeep): Promise { const newItem = await this.createItem(params, data); + + assert(newItem, "Failed to create item from kube"); const items = this.sortItems([...this.items, newItem]); this.items.replace(items); @@ -335,12 +363,9 @@ export abstract class KubeObjectStore extends ItemStore return newItem; } - private postUpdate(rawItem: KubeJsonApiData): T { - const newItem = new this.api.objectConstructor(rawItem); + private postUpdate(newItem: K): K { const index = this.items.findIndex(item => item.getId() === newItem.getId()); - ensureObjectSelfLink(this.api, newItem); - if (index < 0) { this.items.push(newItem); } else { @@ -350,45 +375,49 @@ export abstract class KubeObjectStore extends ItemStore return newItem; } - async patch(item: T, patch: Patch): Promise { - return this.postUpdate( - await this.api.patch( - { - name: item.getName(), namespace: item.getNs(), - }, - patch, - "json", - ), + async patch(item: K, patch: Patch): Promise { + const rawItem = await this.api.patch( + { + name: item.getName(), namespace: item.getNs(), + }, + patch, + "json", ); + + assert(rawItem, `Failed to patch ${item.getScopedName()} of ${item.kind} ${item.apiVersion}`); + + return this.postUpdate(rawItem); } - async update(item: T, data: Partial): Promise { - return this.postUpdate( - await this.api.update( - { - name: item.getName(), - namespace: item.getNs(), - }, - data, - ), + async update(item: K, data: PartialDeep): Promise { + const rawItem = await this.api.update( + { + name: item.getName(), + namespace: item.getNs(), + }, + data, ); + + assert(rawItem, `Failed to update ${item.getScopedName()} of ${item.kind} ${item.apiVersion}`); + + return this.postUpdate(rawItem); } - async remove(item: T) { + async remove(item: K) { await this.api.delete({ name: item.getName(), namespace: item.getNs() }); this.selectedItemsIds.delete(item.getId()); } async removeSelectedItems() { - return Promise.all(this.selectedItems.map(this.remove)); + await Promise.all(this.selectedItems.map(this.remove)); } - async removeItems(items: T[]) { + async removeItems(items: K[]) { await Promise.all(items.map(this.remove)); } // collect items from watch-api events to avoid UI blowing up with huge streams of data - protected eventsBuffer = observable.array>([], { deep: false }); + protected eventsBuffer = observable.array>([], { deep: false }); protected bindWatchEventsUpdater(delay = 1000) { reaction(() => this.eventsBuffer.length, this.updateFromEventsBuffer, { @@ -400,7 +429,9 @@ export abstract class KubeObjectStore extends ItemStore if (this.api.isNamespaced) { Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) .then(() => { - if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { + assert(this.loadedNamespaces); + + if (this.context.cluster?.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { return this.watchNamespace("", abortController, { onLoadFailure }); } @@ -430,7 +461,7 @@ export abstract class KubeObjectStore extends ItemStore const { signal } = abortController; - const callback = (data: IKubeWatchEvent, error: any) => { + const callback: KubeApiWatchCallback = (data, error) => { if (!this.isLoaded || error?.type === "aborted") return; if (error instanceof Response) { diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 05bb769b41..3431c6fcd2 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -6,53 +6,209 @@ // Base class for all kubernetes objects import moment from "moment"; -import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api"; -import { autoBind, formatDuration } from "../utils"; +import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata } from "./kube-json-api"; +import { autoBind, formatDuration, hasOptionalTypedProperty, hasTypedProperty, isObject, isString, isNumber, bindPredicate, isTypedArray, isRecord, json } from "../utils"; import type { ItemObject } from "../item.store"; import { apiKube } from "./index"; -import type { JsonApiParams } from "./json-api"; import * as resourceApplierApi from "./endpoints/resource-applier.api"; -import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; import type { Patch } from "rfc6902"; +import assert from "assert"; +import type { JsonObject } from "type-fest"; -export type KubeObjectConstructor = (new (data: KubeJsonApiData | any) => K) & { - kind?: string; - namespaced?: boolean; - apiBase?: string; +export type KubeJsonApiDataFor = K extends KubeObject + ? KubeJsonApiData, Status, Spec> + : never; + +export interface KubeObjectConstructorData { + readonly kind?: string; + readonly namespaced?: boolean; + readonly apiBase?: string; +} + +export type KubeObjectConstructor, Data> = (new (data: Data) => K) & KubeObjectConstructorData; + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export type KubeTemplateObjectMetadata = Pick, "annotations" | "finalizers" | "generateName" | "labels" | "ownerReferences"> & { + name?: string; + namespace?: ScopedNamespace; }; -/** - * A reference to an object in the same namespace - */ -export interface LocalObjectReference { - name: string; +export interface BaseKubeJsonApiObjectMetadata { + /** + * Annotations is an unstructured key value map stored with a resource that may be set by + * external tools to store and retrieve arbitrary metadata. They are not queryable and should be + * preserved when modifying objects. + * + * More info: http://kubernetes.io/docs/user-guide/annotations + */ + annotations?: Partial>; + + /** + * The name of the cluster which the object belongs to. This is used to distinguish resources + * with same name and namespace in different clusters. This field is not set anywhere right now + * and apiserver is going to ignore it if set in create or update request. + */ + clusterName?: string; + + /** + * CreationTimestamp is a timestamp representing the server time when this object was created. It + * is not guaranteed to be set in happens-before order across separate operations. Clients may + * not set this value. It is represented in RFC3339 form and is in UTC. Populated by the system. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly creationTimestamp?: string; + + /** + * Number of seconds allowed for this object to gracefully terminate before it will be removed + * from the system. Only set when deletionTimestamp is also set. May only be shortened. + */ + readonly deletionGracePeriodSeconds?: number; + + /** + * DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field + * is set by the server when a graceful deletion is requested by the user, and is not directly + * settable by a client. The resource is expected to be deleted (no longer visible from resource + * lists, and not reachable by name) after the time in this field, once the finalizers list is + * empty. As long as the finalizers list contains items, deletion is blocked. Once the + * `deletionTimestamp` is set, this value may not be unset or be set further into the future, + * although it may be shortened or the resource may be deleted prior to this time. For example, + * a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a + * graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet + * will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the + * pod from the API. In the presence of network partitions, this object may still exist after + * this timestamp, until an administrator or automated process can determine the resource is + * fully terminated. If not set, graceful deletion of the object has not been requested. + * Populated by the system when a graceful deletion is requested. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly deletionTimestamp?: string; + + /** + * Must be empty before the object is deleted from the registry. Each entry is an identifier for + * the responsible component that will remove the entry from the list. If the deletionTimestamp + * of the object is non-nil, entries in this list can only be removed. Finalizers may be + * processed and removed in any order. Order is NOT enforced because it introduces significant + * risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder + * it. If the finalizer list is processed in order, then this can lead to a situation in which + * the component responsible for the first finalizer in the list is waiting for a signal (field + * value, external system, or other) produced by a component responsible for a finalizer later in + * the list, resulting in a deadlock. Without enforced ordering finalizers are free to order + * amongst themselves and are not vulnerable to ordering changes in the list. + */ + finalizers?: string[]; + + /** + * GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the + * Name field has not been provided. If this field is used, the name returned to the client will + * be different than the name passed. This value will also be combined with a unique suffix. The + * provided value has the same validation rules as the Name field, and may be truncated by the + * length of the suffix required to make the value unique on the server. If this field is + * specified and the generated name exists, the server will NOT return a 409 - instead, it will + * either return 201 Created or 500 with Reason ServerTimeout indicating a unique name could not + * be found in the time allotted, and the client should retry (optionally after the time indicated + * in the Retry-After header). Applied only if Name is not specified. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + */ + generateName?: string; + + /** + * A sequence number representing a specific generation of the desired state. Populated by the + * system. + */ + readonly generation?: number; + + /** + * Map of string keys and values that can be used to organize and categorize (scope and select) + * objects. May match selectors of replication controllers and services. + * + * More info: http://kubernetes.io/docs/user-guide/labels + */ + labels?: Partial>; + + /** + * ManagedFields maps workflow-id and version to the set of fields that are managed by that + * workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set + * or understand this field. A workflow can be the user's name, a controller's name, or the name + * of a specific apply path like "ci-cd". The set of fields is always in the version that the + * workflow used when modifying the object. + */ + managedFields?: unknown[]; + + /** + * Name must be unique within a namespace. Is required when creating resources, although some + * resources may allow a client to request the generation of an appropriate name automatically. + * Name is primarily intended for creation idempotence and configuration definition. + * + * More info: http://kubernetes.io/docs/user-guide/identifiers#names + */ + readonly name: string; + + /** + * Namespace defines the space within which each name must be unique. An empty namespace is + * equivalent to the "default" namespace, but "default" is the canonical representation. Not all + * objects are required to be scoped to a namespace - the value of this field for those objects + * will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces + */ + readonly namespace?: ScopedNamespace; + + /** + * List of objects depended by this object. If ALL objects in the list have been deleted, this + * object will be garbage collected. If this object is managed by a controller, then an entry in + * this list will point to this controller, with the controller field set to true. There cannot + * be more than one managing controller. + */ + ownerReferences?: OwnerReference[]; + + /** + * An opaque value that represents the internal version of this object that can be used by + * clients to determine when objects have changed. May be used for optimistic concurrency, change + * detection, and the watch operation on a resource or set of resources. Clients must treat these + * values as opaque and passed unmodified back to the server. They may only be valid for a + * particular resource or set of resources. Populated by the system. Value must be treated as + * opaque by clients. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + */ + readonly resourceVersion?: string; + + /** + * SelfLink is a URL representing this object. Populated by the system. + */ + readonly selfLink?: string; + + /** + * UID is the unique in time and space value for this object. It is typically generated by the + * server on successful creation of a resource and is not allowed to change on PUT operations. + * Populated by the system. + * + * More info: http://kubernetes.io/docs/user-guide/identifiers#uids + */ + readonly uid?: string; + + [key: string]: unknown; } -export interface KubeObjectMetadata { - uid: string; - name: string; - namespace?: string; - creationTimestamp: string; - resourceVersion: string; - selfLink: string; - deletionTimestamp?: string; - finalizers?: string[]; - continue?: string; // provided when used "?limit=" query param to fetch objects list - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - ownerReferences?: { - apiVersion: string; - kind: string; - name: string; - uid: string; - controller: boolean; - blockOwnerDeletion: boolean; - }[]; -} +export type KubeJsonApiObjectMetadata = Namespaced extends KubeObjectScope.Namespace + ? BaseKubeJsonApiObjectMetadata & { readonly namespace: string } + : BaseKubeJsonApiObjectMetadata; + +export type KubeObjectMetadata = KubeJsonApiObjectMetadata & { + readonly selfLink: string; + readonly uid: string; + readonly name: string; + readonly resourceVersion: string; +}; export interface KubeStatusData { kind: string; @@ -62,6 +218,16 @@ export interface KubeStatusData { reason?: string; } +export function isKubeStatusData(object: unknown): object is KubeStatusData { + return isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "code", isNumber) + && hasOptionalTypedProperty(object, "message", isString) + && hasOptionalTypedProperty(object, "reason", isString) + && object.kind === "Status"; +} + export class KubeStatus { public readonly kind = "Status"; public readonly apiVersion: string; @@ -77,17 +243,36 @@ export class KubeStatus { } } -export interface KubeObjectStatus { - conditions?: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type?: string; - }[]; +export interface BaseKubeObjectCondition { + /** + * Last time the condition transit from one status to another. + * + * @type Date + */ + lastTransitionTime?: string; + /** + * A human readable message indicating details about last transition. + */ + message?: string; + /** + * brief (usually one word) readon for the condition's last transition. + */ + reason?: string; + /** + * Status of the condition + */ + status: "True" | "False" | "Unknown"; + /** + * Type of the condition + */ + type: string; } -export type KubeMetaField = keyof KubeObjectMetadata; +export interface KubeObjectStatus { + conditions?: BaseKubeObjectCondition[]; +} + +export type KubeMetaField = keyof KubeJsonApiObjectMetadata; export class KubeCreationError extends Error { constructor(message: string, public data: any) { @@ -118,35 +303,98 @@ export type LabelMatchExpression = { } ); +export interface Toleration { + key?: string; + operator?: string; + effect?: string; + value?: string; + tolerationSeconds?: number; +} + +export interface ObjectReference { + apiVersion?: string; + fieldPath?: string; + kind?: string; + name: string; + namespace?: string; + resourceVersion?: string; + uid?: string; +} + +export interface LocalObjectReference { + name: string; +} + export interface TypedLocalObjectReference { apiGroup?: string; kind: string; name: string; } +export interface NodeAffinity { + nodeSelectorTerms?: LabelSelector[]; + weight: number; + preference: LabelSelector; +} + +export interface PodAffinity { + labelSelector: LabelSelector; + topologyKey: string; +} + +export interface SpecificAffinity { + requiredDuringSchedulingIgnoredDuringExecution?: T[]; + preferredDuringSchedulingIgnoredDuringExecution?: T[]; +} + +export interface Affinity { + nodeAffinity?: SpecificAffinity; + podAffinity?: SpecificAffinity; + podAntiAffinity?: SpecificAffinity; +} + export interface LabelSelector { - matchLabels?: Record; + matchLabels?: Partial>; matchExpressions?: LabelMatchExpression[]; } -export class KubeObject implements ItemObject { +export enum KubeObjectScope { + Namespace, + Cluster, +} +export type ScopedNamespace = ( + Namespaced extends KubeObjectScope.Namespace + ? string + : Namespaced extends KubeObjectScope.Cluster + ? undefined + : string | undefined +); + +export class KubeObject< + Status = unknown, + Spec = unknown, + Namespaced extends KubeObjectScope = KubeObjectScope, +> implements ItemObject { static readonly kind?: string; static readonly namespaced?: boolean; static readonly apiBase?: string; - apiVersion: string; - kind: string; - metadata: Metadata; + apiVersion!: string; + kind!: string; + metadata!: KubeObjectMetadata; status?: Status; - spec?: Spec; - managedFields?: any; + spec!: Spec; - static create(data: KubeJsonApiData) { + static create< + Metadata extends KubeObjectMetadata = KubeObjectMetadata, + Status = unknown, + Spec = unknown, + >(data: KubeJsonApiData) { return new KubeObject(data); } - static isNonSystem(item: KubeJsonApiData | KubeObject) { - return !item.metadata.name.startsWith("system:"); + static isNonSystem(item: KubeJsonApiData | KubeObject) { + return !item.metadata.name?.startsWith("system:"); } static isJsonApiData(object: unknown): object is KubeJsonApiData { @@ -161,49 +409,49 @@ export class KubeObject { + static isPartialJsonApiMetadata(object: unknown): object is Partial { return ( isObject(object) - && hasOptionalProperty(object, "uid", isString) - && hasOptionalProperty(object, "name", isString) - && hasOptionalProperty(object, "resourceVersion", isString) - && hasOptionalProperty(object, "selfLink", isString) - && hasOptionalProperty(object, "namespace", isString) - && hasOptionalProperty(object, "creationTimestamp", isString) - && hasOptionalProperty(object, "continue", isString) - && hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) - && hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString)) - && hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + && hasOptionalTypedProperty(object, "uid", isString) + && hasOptionalTypedProperty(object, "name", isString) + && hasOptionalTypedProperty(object, "resourceVersion", isString) + && hasOptionalTypedProperty(object, "selfLink", isString) + && hasOptionalTypedProperty(object, "namespace", isString) + && hasOptionalTypedProperty(object, "creationTimestamp", isString) + && hasOptionalTypedProperty(object, "continue", isString) + && hasOptionalTypedProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalTypedProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalTypedProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) ); } static isPartialJsonApiData(object: unknown): object is Partial { return ( isObject(object) - && hasOptionalProperty(object, "kind", isString) - && hasOptionalProperty(object, "apiVersion", isString) - && hasOptionalProperty(object, "metadata", KubeObject.isPartialJsonApiMetadata) + && hasOptionalTypedProperty(object, "kind", isString) + && hasOptionalTypedProperty(object, "apiVersion", isString) + && hasOptionalTypedProperty(object, "metadata", KubeObject.isPartialJsonApiMetadata) ); } @@ -217,7 +465,7 @@ export class KubeObject>): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`); @@ -240,7 +488,7 @@ export class KubeObject, Status, Spec>) { if (typeof data !== "object") { throw new TypeError(`Cannot create a KubeObject from ${typeof data}`); } @@ -265,13 +513,20 @@ export class KubeObject { // avoid "null" serialization via JSON.stringify when post data - return this.metadata.namespace || undefined; + return (this.metadata.namespace || undefined) as never; } /** @@ -279,6 +534,10 @@ export class KubeObject = { - type: "ADDED" | "MODIFIED" | "DELETED"; - object: T; +export type IKubeWatchEvent = { + readonly type: "ADDED" | "MODIFIED" | "DELETED"; + readonly object: T; } | { - type: "ERROR"; - object: KubeStatusData; + readonly type: "ERROR"; + readonly object?: KubeStatusData; }; diff --git a/src/common/k8s-api/selected-filter-namespaces.injectable.ts b/src/common/k8s-api/selected-filter-namespaces.injectable.ts new file mode 100644 index 0000000000..6c70a665a4 --- /dev/null +++ b/src/common/k8s-api/selected-filter-namespaces.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import namespaceStoreInjectable from "../../renderer/components/+namespaces/store.injectable"; +import { storesAndApisCanBeCreatedInjectionToken } from "./stores-apis-can-be-created.token"; + +const selectedFilterNamespacesInjectable = getInjectable({ + id: "selected-filter-namespaces", + instantiate: (di) => { + if (!di.inject(storesAndApisCanBeCreatedInjectionToken)) { + // Dummy value so that this works in all environments + return computed(() => []); + } + + const store = di.inject(namespaceStoreInjectable); + + return computed(() => [...store.contextNamespaces]); + }, +}); + +export default selectedFilterNamespacesInjectable; diff --git a/src/common/k8s-api/stores-apis-can-be-created.token.ts b/src/common/k8s-api/stores-apis-can-be-created.token.ts new file mode 100644 index 0000000000..746f822c0c --- /dev/null +++ b/src/common/k8s-api/stores-apis-can-be-created.token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const storesAndApisCanBeCreatedInjectionToken = getInjectionToken({ + id: "stores-and-apis-can-be-created-token", +}); diff --git a/src/common/k8s-api/workload-kube-object.ts b/src/common/k8s-api/workload-kube-object.ts deleted file mode 100644 index f612f14cd5..0000000000 --- a/src/common/k8s-api/workload-kube-object.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import get from "lodash/get"; -import { KubeObject } from "./kube-object"; - -export interface IToleration { - key?: string; - operator?: string; - effect?: string; - value?: string; - tolerationSeconds?: number; -} - -interface IMatchExpression { - key: string; - operator: string; - values: string[]; -} - -interface INodeAffinity { - nodeSelectorTerms?: { - matchExpressions: IMatchExpression[]; - }[]; - weight: number; - preference: { - matchExpressions: IMatchExpression[]; - }; -} - -interface IPodAffinity { - labelSelector: { - matchExpressions: IMatchExpression[]; - }; - topologyKey: string; -} - -export interface IAffinity { - nodeAffinity?: { - requiredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[]; - preferredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[]; - }; - podAffinity?: { - requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - }; - podAntiAffinity?: { - requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - }; -} - -export class WorkloadKubeObject extends KubeObject { - getSelectors(): string[] { - const selector = this.spec.selector; - - return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); - } - - getNodeSelectors(): string[] { - const nodeSelector = get(this, "spec.template.spec.nodeSelector"); - - return KubeObject.stringifyLabels(nodeSelector); - } - - getTemplateLabels(): string[] { - const labels = get(this, "spec.template.metadata.labels"); - - return KubeObject.stringifyLabels(labels); - } - - getTolerations(): IToleration[] { - return get(this, "spec.template.spec.tolerations", []); - } - - getAffinity(): IAffinity { - return get(this, "spec.template.spec.affinity"); - } - - getAffinityNumber() { - const affinity = this.getAffinity(); - - if (!affinity) return 0; - - return Object.keys(affinity).length; - } -} diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index 29ade4b84a..d289a375b3 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -59,7 +59,7 @@ export class ResourceStack { throw new Error(response.stderr); } - return response.stdout; + return response.stdout ?? ""; } } @@ -83,7 +83,7 @@ export class ResourceStack { throw new Error(response.stderr); } - return response.stdout; + return response.stdout ?? ""; } } diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 9a04a3f3d8..974b09ddf8 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -11,8 +11,9 @@ import yaml from "js-yaml"; import logger from "../main/logger"; import type { Cluster, Context, User } from "@kubernetes/client-node/dist/config_types"; import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types"; -import { resolvePath } from "./utils"; +import { isDefined, resolvePath } from "./utils"; import Joi from "joi"; +import type { PartialDeep } from "type-fest"; export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); @@ -92,7 +93,7 @@ interface KubeConfigOptions { interface OptionsResult { options: KubeConfigOptions; - error: Joi.ValidationError; + error: Joi.ValidationError | undefined; } function loadToOptions(rawYaml: string): OptionsResult { @@ -135,7 +136,7 @@ export function loadFromOptions(options: KubeConfigOptions): KubeConfig { export interface ConfigResult { config: KubeConfig; - error: Joi.ValidationError; + error: Joi.ValidationError | undefined; } export function loadConfigFromString(content: string): ConfigResult { @@ -188,38 +189,44 @@ export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] { * @param kubeConfig The kubeconfig object to format as pretty yaml * @returns The yaml representation of the kubeconfig object */ -export function dumpConfigYaml(kubeConfig: Partial): string { - const clusters = kubeConfig.clusters.map(cluster => ({ - name: cluster.name, - cluster: { - "certificate-authority-data": cluster.caData, - "certificate-authority": cluster.caFile, - server: cluster.server, - "insecure-skip-tls-verify": cluster.skipTLSVerify, - }, - })); - const contexts = kubeConfig.contexts.map(context => ({ - name: context.name, - context: { - cluster: context.cluster, - user: context.user, - namespace: context.namespace, - }, - })); - const users = kubeConfig.users.map(user => ({ - name: user.name, - user: { - "client-certificate-data": user.certData, - "client-certificate": user.certFile, - "client-key-data": user.keyData, - "client-key": user.keyFile, - "auth-provider": user.authProvider, - exec: user.exec, - token: user.token, - username: user.username, - password: user.password, - }, - })); +export function dumpConfigYaml(kubeConfig: PartialDeep): string { + const clusters = kubeConfig.clusters + ?.filter(isDefined) + .map(cluster => ({ + name: cluster.name, + cluster: { + "certificate-authority-data": cluster.caData, + "certificate-authority": cluster.caFile, + server: cluster.server, + "insecure-skip-tls-verify": cluster.skipTLSVerify, + }, + })); + const contexts = kubeConfig.contexts + ?.filter(isDefined) + .map(context => ({ + name: context.name, + context: { + cluster: context.cluster, + user: context.user, + namespace: context.namespace, + }, + })); + const users = kubeConfig.users + ?.filter(isDefined) + .map(user => ({ + name: user.name, + user: { + "client-certificate-data": user.certData, + "client-certificate": user.certFile, + "client-key-data": user.keyData, + "client-key": user.keyFile, + "auth-provider": user.authProvider, + exec: user.exec, + token: user.token, + username: user.username, + password: user.password, + }, + })); const config = { apiVersion: "v1", kind: "Config", diff --git a/src/common/logger.ts b/src/common/logger.ts index 172f3e2fe8..7df4db08c7 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -15,6 +15,7 @@ export interface Logger { error: (message: string, ...args: any) => void; debug: (message: string, ...args: any) => void; warn: (message: string, ...args: any) => void; + silly: (message: string, ...args: any) => void; } const logLevel = process.env.LOG_LEVEL diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 1740071cfe..476c992582 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -6,7 +6,7 @@ import type { match } from "react-router"; import { matchPath } from "react-router"; import { countBy } from "lodash"; -import { iter } from "../utils"; +import { isDefined, iter } from "../utils"; import { pathToRegexp } from "path-to-regexp"; import logger from "../../main/logger"; import type Url from "url-parse"; @@ -83,7 +83,7 @@ export abstract class LensProtocolRouter { * @param url the parsed URL that initiated the `lens://` protocol * @returns true if a route has been found */ - protected _routeToInternal(url: Url>): RouteAttempt { + protected _routeToInternal(url: Url>): RouteAttempt { return this._route(this.internalRoutes.entries(), url); } @@ -93,7 +93,7 @@ export abstract class LensProtocolRouter { * @param routes the array of path schemas, handler pairs to match against * @param url the url (in its current state) */ - protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url>): null | [match>, RouteHandler] { + protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url>): null | [match>, RouteHandler] { const matches: [match>, RouteHandler][] = []; for (const [schema, handler] of routes) { @@ -120,7 +120,7 @@ export abstract class LensProtocolRouter { * @param routes the array of (path schemas, handler) pairs to match against * @param url the url (in its current state) */ - protected _route(routes: Iterable<[string, RouteHandler]>, url: Url>, extensionName?: string): RouteAttempt { + protected _route(routes: Iterable<[string, RouteHandler]>, url: Url>, extensionName?: string): RouteAttempt { const route = this._findMatchingRoute(routes, url); if (!route) { @@ -158,7 +158,7 @@ export abstract class LensProtocolRouter { * @param url the protocol request URI that was "open"-ed * @returns either the found name or the instance of `LensExtension` */ - protected async _findMatchingExtensionByName(url: Url>): Promise { + protected async _findMatchingExtensionByName(url: Url>): Promise { interface ExtensionUrlMatch { [EXTENSION_PUBLISHER_MATCH]: string; [EXTENSION_NAME_MATCH]: string; @@ -171,7 +171,7 @@ export abstract class LensProtocolRouter { } const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; - const name = [publisher, partialName].filter(Boolean).join("/"); + const name = [publisher, partialName].filter(isDefined).join("/"); const extensionLoader = this.dependencies.extensionLoader; @@ -219,7 +219,7 @@ export abstract class LensProtocolRouter { * Note: this function modifies its argument, do not reuse * @param url the protocol request URI that was "open"-ed */ - protected async _routeToExtension(url: Url>): Promise { + protected async _routeToExtension(url: Url>): Promise { const extension = await this._findMatchingExtensionByName(url); if (typeof extension === "string") { diff --git a/src/common/register-protocol.ts b/src/common/register-protocol.ts deleted file mode 100644 index db00778b2e..0000000000 --- a/src/common/register-protocol.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Register custom protocols - -import { protocol } from "electron"; -import path from "path"; - -export function registerFileProtocol(name: string, basePath: string) { - protocol.registerFileProtocol(name, (request, callback) => { - const filePath = request.url.replace(`${name}://`, ""); - const absPath = path.resolve(basePath, filePath); - - callback({ path: absPath }); - }); -} diff --git a/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts b/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts new file mode 100644 index 0000000000..a7787c6cc4 --- /dev/null +++ b/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type RootFrameRenderedChannel = MessageChannel; + +const rootFrameRenderedChannelInjectable = getInjectable({ + id: "root-frame-rendered-channel", + + instantiate: (): RootFrameRenderedChannel => ({ + id: "root-frame-rendered", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default rootFrameRenderedChannelInjectable; diff --git a/src/common/runnable/run-many-for.test.ts b/src/common/runnable/run-many-for.test.ts new file mode 100644 index 0000000000..193c1fd178 --- /dev/null +++ b/src/common/runnable/run-many-for.test.ts @@ -0,0 +1,267 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { createContainer, getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "./run-many-for"; +import { runManyFor } from "./run-many-for"; +import { getPromiseStatus } from "../test-utils/get-promise-status"; + +describe("runManyFor", () => { + describe("given no hierarchy, when running many", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => Promise>; + let actualPromise: Promise; + + beforeEach(() => { + const rootDi = createContainer(); + + runMock = asyncFn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => ({ run: () => runMock("some-call") }), + injectionToken: someInjectionTokenForRunnables, + }); + + const someOtherInjectable = getInjectable({ + id: "some-other-injectable", + instantiate: () => ({ run: () => runMock("some-other-call") }), + injectionToken: someInjectionTokenForRunnables, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManyFor(rootDi)(someInjectionTokenForRunnables); + + actualPromise = runMany() as Promise; + }); + + it("runs all runnables at the same time", () => { + expect(runMock.mock.calls).toEqual([ + ["some-call"], + ["some-other-call"], + ]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when all runnables resolve, resolves", async () => { + await Promise.all([runMock.resolve(), runMock.resolve()]); + + expect(await actualPromise).toBe(undefined); + }); + }); + + describe("given hierarchy that is three levels deep, when running many", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => Promise>; + let actualPromise: Promise; + + beforeEach(() => { + const di = createContainer(); + + runMock = asyncFn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable1 = getInjectable({ + id: "some-injectable-1", + + instantiate: (di) => ({ + run: () => runMock("third-level-run"), + runAfter: di.inject(someInjectable2), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable2 = getInjectable({ + id: "some-injectable-2", + + instantiate: (di) => ({ + run: () => runMock("second-level-run"), + runAfter: di.inject(someInjectable3), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable3 = getInjectable({ + id: "some-injectable-3", + instantiate: () => ({ run: () => runMock("first-level-run") }), + injectionToken: someInjectionTokenForRunnables, + }); + + di.register(someInjectable1, someInjectable2, someInjectable3); + + const runMany = runManyFor(di)(someInjectionTokenForRunnables); + + actualPromise = runMany() as Promise; + }); + + it("runs first level runnables", () => { + expect(runMock.mock.calls).toEqual([["first-level-run"]]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when first level runnables resolve", () => { + beforeEach(async () => { + runMock.mockClear(); + + await runMock.resolveSpecific(["first-level-run"]); + }); + + it("runs second level runnables", async () => { + expect(runMock.mock.calls).toEqual([["second-level-run"]]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when second level runnables resolve", () => { + beforeEach(async () => { + runMock.mockClear(); + + await runMock.resolveSpecific(["second-level-run"]); + }); + + it("runs final third level runnables", async () => { + expect(runMock.mock.calls).toEqual([["third-level-run"]]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when final third level runnables resolve", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["third-level-run"]); + }); + + it("resolves", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(true); + }); + }); + }); + }); + }); + + it("given invalid hierarchy, when running runnables, throws", () => { + const rootDi = createContainer(); + + const runMock = asyncFn<(...args: unknown[]) => void>(); + + const someInjectionToken = getInjectionToken({ + id: "some-injection-token", + }); + + const someOtherInjectionToken = getInjectionToken({ + id: "some-other-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: (di) => ({ + run: () => runMock("some-runnable-1"), + runAfter: di.inject(someOtherInjectable), + }), + + injectionToken: someInjectionToken, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + run: () => runMock("some-runnable-2"), + }), + + injectionToken: someOtherInjectionToken, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManyFor(rootDi)( + someInjectionToken, + ); + + return expect(() => runMany()).rejects.toThrow( + "Tried to run runnable after other runnable which does not same injection token.", + ); + }); + + describe("when running many with parameter", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => Promise>; + + beforeEach(() => { + const rootDi = createContainer(); + + runMock = asyncFn(); + + const someInjectionTokenForRunnablesWithParameter = getInjectionToken< + Runnable + >({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: () => ({ + run: (parameter) => runMock("run-of-some-runnable-1", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + run: (parameter) => runMock("run-of-some-runnable-2", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManyFor(rootDi)( + someInjectionTokenForRunnablesWithParameter, + ); + + runMany("some-parameter"); + }); + + it("runs all runnables using the parameter", () => { + expect(runMock.mock.calls).toEqual([ + ["run-of-some-runnable-1", "some-parameter"], + ["run-of-some-runnable-2", "some-parameter"], + ]); + }); + }); +}); diff --git a/src/common/runnable/run-many-for.ts b/src/common/runnable/run-many-for.ts new file mode 100644 index 0000000000..f2c1a4ae56 --- /dev/null +++ b/src/common/runnable/run-many-for.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import type { + DiContainerForInjection, + InjectionToken, +} from "@ogre-tools/injectable"; +import { filter, forEach, map, tap } from "lodash/fp"; +import { throwWithIncorrectHierarchyFor } from "./throw-with-incorrect-hierarchy-for"; + +export interface Runnable { + run: Run; + runAfter?: this; +} + +type Run = (parameter: Param) => Promise | void; + +export type RunMany = ( + injectionToken: InjectionToken, void> +) => Run; + +export function runManyFor(di: DiContainerForInjection): RunMany { + return (injectionToken) => async (parameter) => { + const allRunnables = di.injectMany(injectionToken); + + const throwWithIncorrectHierarchy = throwWithIncorrectHierarchyFor(allRunnables); + + const recursedRun = async ( + runAfterRunnable: Runnable | undefined = undefined, + ) => + await pipeline( + allRunnables, + + tap(runnables => forEach(throwWithIncorrectHierarchy, runnables)), + + filter((runnable) => runnable.runAfter === runAfterRunnable), + + map(async (runnable) => { + await runnable.run(parameter); + + await recursedRun(runnable); + }), + + (promises) => Promise.all(promises), + ); + + await recursedRun(); + }; +} diff --git a/src/common/runnable/run-many-sync-for.test.ts b/src/common/runnable/run-many-sync-for.test.ts new file mode 100644 index 0000000000..215b1a3b15 --- /dev/null +++ b/src/common/runnable/run-many-sync-for.test.ts @@ -0,0 +1,197 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { createContainer, getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { RunnableSync } from "./run-many-sync-for"; +import { runManySyncFor } from "./run-many-sync-for"; + +describe("runManySyncFor", () => { + describe("given hierarchy, when running many", () => { + let runMock: jest.Mock; + + beforeEach(() => { + const rootDi = createContainer(); + + runMock = jest.fn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => ({ run: () => runMock("some-call") }), + injectionToken: someInjectionTokenForRunnables, + }); + + const someOtherInjectable = getInjectable({ + id: "some-other-injectable", + instantiate: () => ({ run: () => runMock("some-other-call") }), + injectionToken: someInjectionTokenForRunnables, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManySyncFor(rootDi)(someInjectionTokenForRunnables); + + runMany(); + }); + + it("runs all runnables at the same time", () => { + expect(runMock.mock.calls).toEqual([ + ["some-call"], + ["some-other-call"], + ]); + }); + }); + + describe("given hierarchy that is three levels deep, when running many", () => { + let runMock: jest.Mock<(arg: string) => void>; + + beforeEach(() => { + const di = createContainer(); + + runMock = jest.fn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable1 = getInjectable({ + id: "some-injectable-1", + + instantiate: (di) => ({ + run: () => runMock("third-level-run"), + runAfter: di.inject(someInjectable2), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable2 = getInjectable({ + id: "some-injectable-2", + + instantiate: (di) => ({ + run: () => runMock("second-level-run"), + runAfter: di.inject(someInjectable3), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable3 = getInjectable({ + id: "some-injectable-3", + instantiate: () => ({ run: () => runMock("first-level-run") }), + injectionToken: someInjectionTokenForRunnables, + }); + + di.register(someInjectable1, someInjectable2, someInjectable3); + + const runMany = runManySyncFor(di)(someInjectionTokenForRunnables); + + runMany(); + }); + + it("runs runnables in order", () => { + expect(runMock.mock.calls).toEqual([["first-level-run"], ["second-level-run"], ["third-level-run"]]); + }); + }); + + it("given invalid hierarchy, when running runnables, throws", () => { + const rootDi = createContainer(); + + const runMock = jest.fn(); + + const someInjectionToken = getInjectionToken({ + id: "some-injection-token", + }); + + const someOtherInjectionToken = getInjectionToken({ + id: "some-other-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: (di) => ({ + run: () => runMock("some-runnable-1"), + runAfter: di.inject(someOtherInjectable), + }), + + injectionToken: someInjectionToken, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + run: () => runMock("some-runnable-2"), + }), + + injectionToken: someOtherInjectionToken, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManySyncFor(rootDi)( + someInjectionToken, + ); + + return expect(() => runMany()).rejects.toThrow( + "Tried to run runnable after other runnable which does not same injection token.", + ); + }); + + describe("when running many with parameter", () => { + let runMock: jest.Mock<(arg: string, arg2: string) => void>; + + beforeEach(() => { + const rootDi = createContainer(); + + runMock = jest.fn(); + + const someInjectionTokenForRunnablesWithParameter = getInjectionToken< + RunnableSync + >({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: () => ({ + run: (parameter) => runMock("run-of-some-runnable-1", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + run: (parameter) => runMock("run-of-some-runnable-2", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManySyncFor(rootDi)( + someInjectionTokenForRunnablesWithParameter, + ); + + runMany("some-parameter"); + }); + + it("runs all runnables using the parameter", () => { + expect(runMock.mock.calls).toEqual([ + ["run-of-some-runnable-1", "some-parameter"], + ["run-of-some-runnable-2", "some-parameter"], + ]); + }); + }); +}); + diff --git a/src/common/runnable/run-many-sync-for.ts b/src/common/runnable/run-many-sync-for.ts new file mode 100644 index 0000000000..cfe93fa4b3 --- /dev/null +++ b/src/common/runnable/run-many-sync-for.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import type { + DiContainerForInjection, + InjectionToken, +} from "@ogre-tools/injectable"; +import { filter, forEach, map, tap } from "lodash/fp"; +import type { Runnable } from "./run-many-for"; +import { throwWithIncorrectHierarchyFor } from "./throw-with-incorrect-hierarchy-for"; + +export interface RunnableSync { + run: RunSync; + runAfter?: this; +} + +type RunSync = (parameter: Param) => void; + +export type RunManySync = ( + injectionToken: InjectionToken, void> +) => RunSync; + +export function runManySyncFor(di: DiContainerForInjection): RunManySync { + return (injectionToken) => async (parameter) => { + const allRunnables = di.injectMany(injectionToken); + + const throwWithIncorrectHierarchy = throwWithIncorrectHierarchyFor(allRunnables); + + const recursedRun = ( + runAfterRunnable: RunnableSync | undefined = undefined, + ) => + pipeline( + allRunnables, + + tap(runnables => forEach(throwWithIncorrectHierarchy, runnables)), + + filter((runnable) => runnable.runAfter === runAfterRunnable), + + map((runnable) => { + runnable.run(parameter); + + recursedRun(runnable); + }), + ); + + recursedRun(); + }; +} diff --git a/src/common/runnable/throw-with-incorrect-hierarchy-for.ts b/src/common/runnable/throw-with-incorrect-hierarchy-for.ts new file mode 100644 index 0000000000..03073c4044 --- /dev/null +++ b/src/common/runnable/throw-with-incorrect-hierarchy-for.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Runnable } from "./run-many-for"; +import type { RunnableSync } from "./run-many-sync-for"; + +export const throwWithIncorrectHierarchyFor = + (allRunnables: Runnable[] | RunnableSync[]) => + (runnable: Runnable | RunnableSync) => { + if (runnable.runAfter && !allRunnables.includes(runnable.runAfter)) { + throw new Error( + "Tried to run runnable after other runnable which does not same injection token.", + ); + } + }; diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts index 36886dba63..4e57ca2d7b 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -85,7 +85,7 @@ export async function injectSystemCAs() { injectCAs(osxRootCAs); } catch (error) { - console.warn(`[MAC-CA]: Error injecting root CAs from MacOSX. ${error?.message}`); + console.warn(`[MAC-CA]: Error injecting root CAs from MacOSX. ${error}`); } } @@ -96,7 +96,7 @@ export async function injectSystemCAs() { wincaAPI.inject("+", winRootCAs); } catch (error) { - console.warn(`[WIN-CA]: Error injecting root CAs from Windows. ${error?.message}`); + console.warn(`[WIN-CA]: Error injecting root CAs from Windows. ${error}`); } } } diff --git a/src/common/terminal/channels.ts b/src/common/terminal/channels.ts new file mode 100644 index 0000000000..f958c9c696 --- /dev/null +++ b/src/common/terminal/channels.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export enum TerminalChannels { + STDIN = "stdin", + STDOUT = "stdout", + CONNECTED = "connected", + RESIZE = "resize", + PING = "ping", +} + +export type TerminalMessage = { + type: TerminalChannels.STDIN; + data: string; +} | { + type: TerminalChannels.STDOUT; + data: string; +} | { + type: TerminalChannels.CONNECTED; +} | { + type: TerminalChannels.RESIZE; + data: { + width: number; + height: number; + }; +} | { + type: TerminalChannels.PING; +}; diff --git a/src/common/test-utils/flush-promises.ts b/src/common/test-utils/flush-promises.ts new file mode 100644 index 0000000000..55335fe445 --- /dev/null +++ b/src/common/test-utils/flush-promises.ts @@ -0,0 +1,5 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export const flushPromises = () => new Promise(setImmediate); diff --git a/src/common/test-utils/get-promise-status.ts b/src/common/test-utils/get-promise-status.ts new file mode 100644 index 0000000000..8c171fbe54 --- /dev/null +++ b/src/common/test-utils/get-promise-status.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { flushPromises } from "./flush-promises"; + +export const getPromiseStatus = async (promise: Promise) => { + const status = { fulfilled: false }; + + promise.finally(() => { + status.fulfilled = true; + }); + + await flushPromises(); + + return status; +}; diff --git a/src/common/user-store/file-name-migration.injectable.ts b/src/common/user-store/file-name-migration.injectable.ts new file mode 100644 index 0000000000..8f2dcf1e23 --- /dev/null +++ b/src/common/user-store/file-name-migration.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import fse from "fs-extra"; +import path from "path"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isErrnoException } from "../utils"; +import { getInjectable } from "@ogre-tools/injectable"; + +const userStoreFileNameMigrationInjectable = getInjectable({ + id: "user-store-file-name-migration", + instantiate: (di) => { + const userDataPath = di.inject(directoryForUserDataInjectable); + const configJsonPath = path.join(userDataPath, "config.json"); + const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); + + try { + fse.moveSync(configJsonPath, lensUserStoreJsonPath); + } catch (error) { + if (error instanceof Error && error.message === "dest already exists.") { + fse.removeSync(configJsonPath); + } else if (isErrnoException(error) && error.code === "ENOENT" && error.path === configJsonPath) { + // (No such file or directory) + return; // file already moved + } else { + // pass other errors along + throw error; + } + } + }, +}); + +export default userStoreFileNameMigrationInjectable; diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts index 5e1fc597f3..ed3fb7c249 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-store/preferences-helpers.ts @@ -6,11 +6,11 @@ import moment from "moment-timezone"; import path from "path"; import os from "os"; -import { getAppVersion } from "../utils"; import type { editor } from "monaco-editor"; import merge from "lodash/merge"; -import { SemVer } from "semver"; -import { defaultTheme, defaultEditorFontFamily, defaultFontSize, defaultTerminalFontFamily } from "../vars"; +import { defaultThemeId, defaultEditorFontFamily, defaultFontSize, defaultTerminalFontFamily } from "../vars"; +import type { ObservableMap } from "mobx"; +import { observable } from "mobx"; export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; @@ -28,8 +28,11 @@ export const defaultTerminalConfig: TerminalConfig = { fontFamily: defaultTerminalFontFamily, }; -export type EditorConfiguration = Pick; +interface BaseEditorConfiguration extends Required> { + lineNumbers: NonNullable>; +} + +export type EditorConfiguration = Required; export const defaultEditorConfig: EditorConfiguration = { tabSize: 2, @@ -41,7 +44,12 @@ export const defaultEditorConfig: EditorConfiguration = { side: "right", }, }; -interface PreferenceDescription { + +export type StoreType

= P extends PreferenceDescription + ? Store + : never; + +export interface PreferenceDescription { fromStore(val: T | undefined): R; toStore(val: R): T | undefined; } @@ -66,10 +74,10 @@ const shell: PreferenceDescription = { const colorTheme: PreferenceDescription = { fromStore(val) { - return val || defaultTheme; + return val || defaultThemeId; }, toStore(val) { - if (!val || val === defaultTheme) { + if (!val || val === defaultThemeId) { return undefined; } @@ -77,7 +85,7 @@ const colorTheme: PreferenceDescription = { }, }; -const terminalTheme: PreferenceDescription = { +const terminalTheme: PreferenceDescription = { fromStore(val) { return val || ""; }, @@ -86,12 +94,14 @@ const terminalTheme: PreferenceDescription = { }, }; +export const defaultLocaleTimezone = "UTC"; + const localeTimezone: PreferenceDescription = { fromStore(val) { - return val || moment.tz.guess(true) || "UTC"; + return val || moment.tz.guess(true) || defaultLocaleTimezone; }, toStore(val) { - if (!val || val === moment.tz.guess(true) || val === "UTC") { + if (!val || val === moment.tz.guess(true) || val === defaultLocaleTimezone) { return undefined; } @@ -112,19 +122,6 @@ const allowUntrustedCAs: PreferenceDescription = { }, }; -const allowTelemetry: PreferenceDescription = { - fromStore(val) { - return val ?? true; - }, - toStore(val) { - if (val === true) { - return undefined; - } - - return val; - }, -}; - const allowErrorReporting: PreferenceDescription = { fromStore(val) { return val ?? true; @@ -145,12 +142,14 @@ export interface DownloadMirror { } export const defaultPackageMirror = "default"; +const defaultDownloadMirrorData: DownloadMirror = { + url: "https://storage.googleapis.com/kubernetes-release/release", + label: "Default (Google)", + platforms: new Set(["darwin", "win32", "linux"]), +}; + export const packageMirrors = new Map([ - [defaultPackageMirror, { - url: "https://storage.googleapis.com/kubernetes-release/release", - label: "Default (Google)", - platforms: new Set(["darwin", "win32", "linux"]), - }], + [defaultPackageMirror, defaultDownloadMirrorData], ["china", { url: "https://mirror.azure.cn/kubernetes/kubectl", label: "China (Azure)", @@ -160,7 +159,9 @@ export const packageMirrors = new Map([ const downloadMirror: PreferenceDescription = { fromStore(val) { - return packageMirrors.has(val) ? val : defaultPackageMirror; + return !val || !packageMirrors.has(val) + ? defaultPackageMirror + : val; }, toStore(val) { if (!val || val === defaultPackageMirror) { @@ -257,9 +258,9 @@ const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map> = { +const syncKubeconfigEntries: PreferenceDescription> = { fromStore(val) { - return new Map( + return observable.map( val ?.map(({ filePath, ...rest }) => [filePath, rest]) ?? [[mainKubeFolder, {}]], @@ -274,7 +275,7 @@ const syncKubeconfigEntries: PreferenceDescription = { +const editorConfiguration: PreferenceDescription | undefined, EditorConfiguration> = { fromStore(val) { return merge(defaultEditorConfig, val); }, @@ -292,55 +293,27 @@ const terminalConfig: PreferenceDescription = { }, }; -const updateChannels = new Map([ - ["latest", { - label: "Stable", - }], - ["beta", { - label: "Beta", - }], - ["alpha", { - label: "Alpha", - }], -]); -const defaultUpdateChannel = new SemVer(getAppVersion()).prerelease[0]?.toString() || "latest"; +export type ExtensionRegistryLocation = "default" | "npmrc" | "custom"; -const updateChannel: PreferenceDescription = { - fromStore(val) { - return updateChannels.has(val) ? val : defaultUpdateChannel; - }, - toStore(val) { - if (!updateChannels.has(val) || val === defaultUpdateChannel) { - return undefined; - } - - return val; - }, -}; - -export enum ExtensionRegistryLocation { - DEFAULT = "default", - NPMRC = "npmrc", - CUSTOM = "custom", -} export type ExtensionRegistry = { - location: ExtensionRegistryLocation.DEFAULT | ExtensionRegistryLocation.NPMRC; + location: "default" | "npmrc"; customUrl?: undefined; } | { - location: ExtensionRegistryLocation.CUSTOM; + location: "custom"; customUrl: string; }; +export const defaultExtensionRegistryUrlLocation = "default"; export const defaultExtensionRegistryUrl = "https://registry.npmjs.org"; const extensionRegistryUrl: PreferenceDescription = { fromStore(val) { return val ?? { - location: ExtensionRegistryLocation.DEFAULT, + location: defaultExtensionRegistryUrlLocation, }; }, toStore(val) { - if (val.location === ExtensionRegistryLocation.DEFAULT) { + if (val.location === defaultExtensionRegistryUrlLocation) { return undefined; } @@ -357,7 +330,7 @@ export type UserStoreFlatModel = { export type UserPreferencesModel = { [field in keyof typeof DESCRIPTORS]: PreferencesModelType; -}; +} & { updateChannel: string }; export const DESCRIPTORS = { httpsProxy, @@ -366,7 +339,6 @@ export const DESCRIPTORS = { terminalTheme, localeTimezone, allowUntrustedCAs, - allowTelemetry, allowErrorReporting, downloadMirror, downloadKubectlBinaries, @@ -378,10 +350,5 @@ export const DESCRIPTORS = { editorConfiguration, terminalCopyOnSelect, terminalConfig, - updateChannel, extensionRegistryUrl, }; - -export const CONSTANTS = { - updateChannels, -}; diff --git a/src/common/user-store/terminal-config.injectable.ts b/src/common/user-store/terminal-config.injectable.ts new file mode 100644 index 0000000000..6f5be75eaf --- /dev/null +++ b/src/common/user-store/terminal-config.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { toJS } from "../utils"; +import userStoreInjectable from "./user-store.injectable"; + +const terminalConfigInjectable = getInjectable({ + id: "terminal-config", + instantiate: (di) => { + const store = di.inject(userStoreInjectable); + + return computed(() => toJS(store.terminalConfig)); + }, +}); + +export default terminalConfigInjectable; diff --git a/src/common/user-store/terminal-copy-on-select.injectable.ts b/src/common/user-store/terminal-copy-on-select.injectable.ts new file mode 100644 index 0000000000..543a4f73b9 --- /dev/null +++ b/src/common/user-store/terminal-copy-on-select.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userStoreInjectable from "./user-store.injectable"; + +const terminalCopyOnSelectInjectable = getInjectable({ + id: "terminal-copy-on-select", + instantiate: (di) => { + const store = di.inject(userStoreInjectable); + + return computed(() => store.terminalCopyOnSelect); + }, +}); + +export default terminalCopyOnSelectInjectable; diff --git a/src/common/user-store/user-store.injectable.ts b/src/common/user-store/user-store.injectable.ts index 9a34144828..3b4aba0b56 100644 --- a/src/common/user-store/user-store.injectable.ts +++ b/src/common/user-store/user-store.injectable.ts @@ -3,15 +3,24 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import { ipcMain } from "electron"; +import userStoreFileNameMigrationInjectable from "./file-name-migration.injectable"; import { UserStore } from "./user-store"; +import selectedUpdateChannelInjectable from "../application-update/selected-update-channel/selected-update-channel.injectable"; const userStoreInjectable = getInjectable({ id: "user-store", - instantiate: () => { + instantiate: (di) => { UserStore.resetInstance(); - return UserStore.createInstance(); + if (ipcMain) { + di.inject(userStoreFileNameMigrationInjectable); + } + + return UserStore.createInstance({ + selectedUpdateChannel: di.inject(selectedUpdateChannelInjectable), + }); }, causesSideEffects: true, diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index a948def9d8..b806732735 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -3,38 +3,40 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { app, ipcMain } from "electron"; -import semver, { SemVer } from "semver"; +import { app } from "electron"; +import semver from "semver"; import { action, computed, observable, reaction, makeObservable, isObservableArray, isObservableSet, isObservableMap } from "mobx"; import { BaseStore } from "../base-store"; -import migrations, { fileNameMigration } from "../../migrations/user-store"; +import migrations from "../../migrations/user-store"; import { getAppVersion } from "../utils/app-version"; import { kubeConfigDefaultPath } from "../kube-helpers"; import { appEventBus } from "../app-event-bus/event-bus"; -import { getOrInsertSet, toggle, toJS, entries, fromEntries } from "../../renderer/utils"; +import { getOrInsertSet, toggle, toJS, object } from "../../renderer/utils"; import { DESCRIPTORS } from "./preferences-helpers"; -import type { EditorConfiguration, ExtensionRegistry, KubeconfigSyncValue, UserPreferencesModel, TerminalConfig } from "./preferences-helpers"; +import type { UserPreferencesModel, StoreType } from "./preferences-helpers"; import logger from "../../main/logger"; +import type { SelectedUpdateChannel } from "../application-update/selected-update-channel/selected-update-channel.injectable"; +import type { UpdateChannelId } from "../application-update/update-channels"; export interface UserStoreModel { lastSeenAppVersion: string; preferences: UserPreferencesModel; } +interface Dependencies { + selectedUpdateChannel: SelectedUpdateChannel; +} + export class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { readonly displayName = "UserStore"; - constructor() { + + constructor(private readonly dependencies: Dependencies) { super({ configName: "lens-user-store", migrations, }); makeObservable(this); - - if (ipcMain) { - fileNameMigration(); - } - this.load(); } @@ -42,47 +44,59 @@ export class UserStore extends BaseStore /* implements UserStore /** * used in add-cluster page for providing context + * @deprecated No longer used */ @observable kubeConfigPath = kubeConfigDefaultPath; + + /** + * @deprecated No longer used + */ @observable seenContexts = observable.set(); + + /** + * @deprecated No longer used + */ @observable newContexts = observable.set(); - @observable allowTelemetry: boolean; - @observable allowErrorReporting: boolean; - @observable allowUntrustedCAs: boolean; - @observable colorTheme: string; - @observable terminalTheme: string; - @observable localeTimezone: string; - @observable downloadMirror: string; - @observable httpsProxy?: string; - @observable shell?: string; - @observable downloadBinariesPath?: string; - @observable kubectlBinariesPath?: string; - @observable terminalCopyOnSelect: boolean; - @observable terminalConfig: TerminalConfig; - @observable updateChannel?: string; - @observable extensionRegistryUrl: ExtensionRegistry; + + @observable allowErrorReporting!: StoreType; + @observable allowUntrustedCAs!: StoreType; + @observable colorTheme!: StoreType; + @observable terminalTheme!: StoreType; + @observable localeTimezone!: StoreType; + @observable downloadMirror!: StoreType; + @observable httpsProxy!: StoreType; + @observable shell!: StoreType; + @observable downloadBinariesPath!: StoreType; + @observable kubectlBinariesPath!: StoreType; + @observable terminalCopyOnSelect!: StoreType; + @observable terminalConfig!: StoreType; + @observable extensionRegistryUrl!: StoreType; /** * Download kubectl binaries matching cluster version */ - @observable downloadKubectlBinaries: boolean; - @observable openAtLogin: boolean; + @observable downloadKubectlBinaries!: StoreType; + + /** + * Whether the application should open itself at login. + */ + @observable openAtLogin!: StoreType; /** * The column IDs under each configurable table ID that have been configured * to not be shown */ - hiddenTableColumns = observable.map>(); + @observable hiddenTableColumns!: StoreType; /** * Monaco editor configs */ - @observable editorConfiguration: EditorConfiguration; + @observable editorConfiguration!: StoreType; /** * The set of file/folder paths to be synced */ - syncKubeconfigEntries = observable.map(); + @observable syncKubeconfigEntries!: StoreType; @computed get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); @@ -92,16 +106,7 @@ export class UserStore extends BaseStore /* implements UserStore return this.shell || process.env.SHELL || process.env.PTYSHELL; } - @computed get isAllowedToDowngrade() { - return new SemVer(getAppVersion()).prerelease[0] !== this.updateChannel; - } - startMainReactions() { - // track telemetry availability - reaction(() => this.allowTelemetry, allowed => { - appEventBus.emit({ name: "telemetry", action: allowed ? "enabled" : "disabled" }); - }); - // open at system start-up reaction(() => this.openAtLogin, openAtLogin => { app.setLoginItemSettings({ @@ -120,7 +125,7 @@ export class UserStore extends BaseStore /* implements UserStore * @param columnIds The list of IDs the check if one is hidden * @returns true if at least one column under the table is set to hidden */ - isTableColumnHidden(tableId: string, ...columnIds: string[]): boolean { + isTableColumnHidden(tableId: string, ...columnIds: (string | undefined)[]): boolean { if (columnIds.length === 0) { return false; } @@ -131,7 +136,7 @@ export class UserStore extends BaseStore /* implements UserStore return false; } - return columnIds.some(columnId => config.has(columnId)); + return columnIds.some(columnId => columnId && config.has(columnId)); } /** @@ -152,11 +157,6 @@ export class UserStore extends BaseStore /* implements UserStore this.lastSeenAppVersion = getAppVersion(); } - @action - setLocaleTimezone(tz: string) { - this.localeTimezone = tz; - } - @action protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { logger.debug("UserStore.fromStore()", { lastSeenAppVersion, preferences }); @@ -165,7 +165,7 @@ export class UserStore extends BaseStore /* implements UserStore this.lastSeenAppVersion = lastSeenAppVersion; } - for (const [key, { fromStore }] of entries(DESCRIPTORS)) { + for (const [key, { fromStore }] of object.entries(DESCRIPTORS)) { const curVal = this[key]; const newVal = fromStore((preferences)?.[key] as never) as never; @@ -177,17 +177,27 @@ export class UserStore extends BaseStore /* implements UserStore this[key] = newVal; } } + + // TODO: Switch to action-based saving instead saving stores by reaction + if (preferences?.updateChannel) { + this.dependencies.selectedUpdateChannel.setValue(preferences?.updateChannel as UpdateChannelId); + } } toJSON(): UserStoreModel { - const preferences = fromEntries( - entries(DESCRIPTORS) + const preferences = object.fromEntries( + object.entries(DESCRIPTORS) .map(([key, { toStore }]) => [key, toStore(this[key] as never)]), ) as UserPreferencesModel; return toJS({ lastSeenAppVersion: this.lastSeenAppVersion, - preferences, + + preferences: { + ...preferences, + + updateChannel: this.dependencies.selectedUpdateChannel.value.get().id, + }, }); } } diff --git a/src/common/utils/__tests__/iter.test.ts b/src/common/utils/__tests__/iter.test.ts index 050f5d0648..e41894e662 100644 --- a/src/common/utils/__tests__/iter.test.ts +++ b/src/common/utils/__tests__/iter.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { reduce } from "../iter"; +import { join, nth, reduce } from "../iter"; describe("iter", () => { describe("reduce", () => { @@ -15,4 +15,28 @@ describe("iter", () => { expect(reduce([], (acc: number[], current: number) => [acc[0] + current], [])).toEqual([]); }); }); + + describe("join", () => { + it("should not prefix the output by the seperator", () => { + expect(join(["a", "b", "c"].values(), " ")).toBe("a b c"); + }); + + it("should return empty string if iterator is empty", () => { + expect(join([].values(), " ")).toBe(""); + }); + + it("should return just first entry if iterator is of size 1", () => { + expect(join(["d"].values(), " ")).toBe("d"); + }); + }); + + describe("nth", () => { + it("should return undefined past the end of the iterator", () => { + expect(nth(["a"], 123)).toBeUndefined(); + }); + + it("should by 0-indexing the index", () => { + expect(nth(["a", "b"], 0)).toBe("a"); + }); + }); }); diff --git a/src/common/utils/autobind.ts b/src/common/utils/autobind.ts index a543781715..49feb435e3 100644 --- a/src/common/utils/autobind.ts +++ b/src/common/utils/autobind.ts @@ -6,11 +6,12 @@ import type { Options } from "auto-bind"; import autoBindClass from "auto-bind"; import autoBindReactClass from "auto-bind/react"; +import React from "react"; // Automatically bind methods to their class instance export function autoBind(obj: T, opts?: Options): T { - if ("componentWillUnmount" in obj) { - return autoBindReactClass(obj as any, opts); + if (obj instanceof React.Component) { + return autoBindReactClass(obj, opts); } return autoBindClass(obj, opts); diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index 787086f027..61639cea4d 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -4,6 +4,12 @@ */ import { compile } from "path-to-regexp"; +import type { RouteProps } from "react-router"; +import { isDefined } from "./type-narrowing"; + +export interface UrlRouteProps extends RouteProps { + path: string; +} export interface URLParams

{ params?: P; @@ -21,7 +27,7 @@ export function buildURL

(path: str fragment && `#${fragment}`, ]; - return parts.filter(Boolean).join(""); + return parts.filter(isDefined).join(""); } export function buildURLPositional

(path: string) { diff --git a/src/common/utils/camelCase.ts b/src/common/utils/camelCase.ts index 3f30696a10..3ef727a2e5 100644 --- a/src/common/utils/camelCase.ts +++ b/src/common/utils/camelCase.ts @@ -4,22 +4,26 @@ */ // Convert object's keys to camelCase format -import { camelCase, isPlainObject } from "lodash"; +import { camelCase } from "lodash"; +import type { SingleOrMany } from "./types"; +import { isObject } from "./type-narrowing"; -export function toCamelCase(obj: Record): any { +export function toCamelCase[]>(obj: T): T; +export function toCamelCase>(obj: T): T; + +export function toCamelCase(obj: SingleOrMany | unknown>): SingleOrMany | unknown> { if (Array.isArray(obj)) { return obj.map(toCamelCase); } - else if (isPlainObject(obj)) { - return Object.keys(obj).reduce((result, key) => { - const value = obj[key]; - result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value; + if (isObject(obj)) { + return Object.fromEntries( + Object.entries(obj) + .map(([key, value]) => { + return [camelCase(key), toCamelCase(value)]; + }), + ); + } - return result; - }, {} as any); - } - else { - return obj; - } + return obj; } diff --git a/src/common/utils/channel/channel-injection-token.ts b/src/common/utils/channel/channel-injection-token.ts new file mode 100644 index 0000000000..6006290f89 --- /dev/null +++ b/src/common/utils/channel/channel-injection-token.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export interface Channel { + id: string; + _messageTemplate?: MessageTemplate; + _returnTemplate?: ReturnTemplate; +} + diff --git a/src/common/utils/channel/channel.test.ts b/src/common/utils/channel/channel.test.ts new file mode 100644 index 0000000000..f2748104d7 --- /dev/null +++ b/src/common/utils/channel/channel.test.ts @@ -0,0 +1,273 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { LensWindow } from "../../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import { lensWindowInjectionToken } from "../../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import type { MessageToChannel } from "./message-to-channel-injection-token"; +import { messageToChannelInjectionToken } from "./message-to-channel-injection-token"; +import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import createLensWindowInjectable from "../../../main/start-main-application/lens-window/application-window/create-lens-window.injectable"; +import closeAllWindowsInjectable from "../../../main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import type { MessageChannel } from "./message-channel-injection-token"; +import type { RequestFromChannel } from "./request-from-channel-injection-token"; +import { requestFromChannelInjectionToken } from "./request-from-channel-injection-token"; +import type { RequestChannel } from "./request-channel-injection-token"; +import { requestChannelListenerInjectionToken } from "./request-channel-listener-injection-token"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; + +type TestMessageChannel = MessageChannel; +type TestRequestChannel = RequestChannel; + +describe("channel", () => { + describe("messaging from main to renderer, given listener for channel in a window and application has started", () => { + let testMessageChannel: TestMessageChannel; + let messageListenerInWindowMock: jest.Mock; + let mainDi: DiContainer; + let messageToChannel: MessageToChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + mainDi = applicationBuilder.dis.mainDi; + const rendererDi = applicationBuilder.dis.rendererDi; + + messageListenerInWindowMock = jest.fn(); + + const testChannelListenerInTestWindowInjectable = getInjectable({ + id: "test-channel-listener-in-test-window", + + instantiate: (di) => ({ + channel: di.inject(testMessageChannelInjectable), + + handler: messageListenerInWindowMock, + }), + + injectionToken: messageChannelListenerInjectionToken, + }); + + rendererDi.register(testChannelListenerInTestWindowInjectable); + + // Notice how test channel has presence in both DIs, being from common + mainDi.register(testMessageChannelInjectable); + rendererDi.register(testMessageChannelInjectable); + + testMessageChannel = mainDi.inject(testMessageChannelInjectable); + + messageToChannel = mainDi.inject( + messageToChannelInjectionToken, + ); + + await applicationBuilder.render(); + + const closeAllWindows = mainDi.inject(closeAllWindowsInjectable); + + closeAllWindows(); + }); + + describe("given window is shown", () => { + let someWindowFake: LensWindow; + + beforeEach(async () => { + someWindowFake = createTestWindow(mainDi, "some-window"); + + await someWindowFake.show(); + }); + + it("when sending message, triggers listener in window", () => { + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock).toHaveBeenCalledWith("some-message"); + }); + + it("given window is hidden, when sending message, does not trigger listener in window", () => { + someWindowFake.close(); + + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock).not.toHaveBeenCalled(); + }); + }); + + it("given multiple shown windows, when sending message, triggers listeners in all windows", async () => { + const someWindowFake = createTestWindow(mainDi, "some-window"); + const someOtherWindowFake = createTestWindow(mainDi, "some-other-window"); + + await someWindowFake.show(); + await someOtherWindowFake.show(); + + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock.mock.calls).toEqual([ + ["some-message"], + ["some-message"], + ]); + }); + }); + + describe("messaging from renderer to main, given listener for channel in a main and application has started", () => { + let testMessageChannel: TestMessageChannel; + let messageListenerInMainMock: jest.Mock; + let rendererDi: DiContainer; + let mainDi: DiContainer; + let messageToChannel: MessageToChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + mainDi = applicationBuilder.dis.mainDi; + rendererDi = applicationBuilder.dis.rendererDi; + + messageListenerInMainMock = jest.fn(); + + const testChannelListenerInMainInjectable = getInjectable({ + id: "test-channel-listener-in-main", + + instantiate: (di) => ({ + channel: di.inject(testMessageChannelInjectable), + + handler: messageListenerInMainMock, + }), + + injectionToken: messageChannelListenerInjectionToken, + }); + + mainDi.register(testChannelListenerInMainInjectable); + + // Notice how test channel has presence in both DIs, being from common + mainDi.register(testMessageChannelInjectable); + rendererDi.register(testMessageChannelInjectable); + + testMessageChannel = rendererDi.inject(testMessageChannelInjectable); + + messageToChannel = rendererDi.inject( + messageToChannelInjectionToken, + ); + + await applicationBuilder.render(); + }); + + it("when sending message, triggers listener in main", () => { + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInMainMock).toHaveBeenCalledWith("some-message"); + }); + }); + + describe("requesting from main in renderer, given listener for channel in a main and application has started", () => { + let testRequestChannel: TestRequestChannel; + let requestListenerInMainMock: AsyncFnMock<(arg: string) => string>; + let rendererDi: DiContainer; + let mainDi: DiContainer; + let requestFromChannel: RequestFromChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + mainDi = applicationBuilder.dis.mainDi; + rendererDi = applicationBuilder.dis.rendererDi; + + requestListenerInMainMock = asyncFn(); + + const testChannelListenerInMainInjectable = getInjectable({ + id: "test-channel-listener-in-main", + + instantiate: (di) => ({ + channel: di.inject(testRequestChannelInjectable), + + handler: requestListenerInMainMock, + }), + + injectionToken: requestChannelListenerInjectionToken, + }); + + mainDi.register(testChannelListenerInMainInjectable); + + // Notice how test channel has presence in both DIs, being from common + mainDi.register(testRequestChannelInjectable); + rendererDi.register(testRequestChannelInjectable); + + testRequestChannel = rendererDi.inject(testRequestChannelInjectable); + + requestFromChannel = rendererDi.inject( + requestFromChannelInjectionToken, + ); + + await applicationBuilder.render(); + }); + + describe("when requesting from channel", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = requestFromChannel(testRequestChannel, "some-request"); + }); + + it("triggers listener in main", () => { + expect(requestListenerInMainMock).toHaveBeenCalledWith("some-request"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when main resolves with response, resolves with response", async () => { + await requestListenerInMainMock.resolve("some-response"); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + }); + }); +}); + +const testMessageChannelInjectable = getInjectable({ + id: "some-message-test-channel", + + instantiate: (): TestMessageChannel => ({ + id: "some-message-channel-id", + }), +}); + +const testRequestChannelInjectable = getInjectable({ + id: "some-request-test-channel", + + instantiate: (): TestRequestChannel => ({ + id: "some-request-channel-id", + }), +}); + +const createTestWindow = (di: DiContainer, id: string) => { + const testWindowInjectable = getInjectable({ + id, + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + + return createLensWindow({ + id, + title: "Some test window", + defaultHeight: 42, + defaultWidth: 42, + getContentSource: () => ({ url: "some-content-url" }), + resizable: true, + windowFrameUtilitiesAreShown: false, + centered: false, + }); + }, + + injectionToken: lensWindowInjectionToken, + }); + + di.register(testWindowInjectable); + + return di.inject(testWindowInjectable); +}; diff --git a/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts b/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..fa6983e130 --- /dev/null +++ b/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MessageChannel } from "./message-channel-injection-token"; +import type { MessageChannelListener } from "./message-channel-listener-injection-token"; + +export type EnlistMessageChannelListener = < + TChannel extends MessageChannel, +>(listener: MessageChannelListener) => () => void; + +export const enlistMessageChannelListenerInjectionToken = + getInjectionToken({ + id: "enlist-message-channel-listener", + }); diff --git a/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts b/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..f87082c466 --- /dev/null +++ b/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RequestChannel } from "./request-channel-injection-token"; +import type { RequestChannelListener } from "./request-channel-listener-injection-token"; + +export type EnlistRequestChannelListener = < + TChannel extends RequestChannel, +>(listener: RequestChannelListener) => () => void; + +export const enlistRequestChannelListenerInjectionToken = + getInjectionToken({ + id: "enlist-request-channel-listener", + }); diff --git a/src/common/utils/channel/listening-of-channels.injectable.ts b/src/common/utils/channel/listening-of-channels.injectable.ts new file mode 100644 index 0000000000..30fee42fb9 --- /dev/null +++ b/src/common/utils/channel/listening-of-channels.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getStartableStoppable } from "../get-startable-stoppable"; +import { disposer } from "../index"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import { requestChannelListenerInjectionToken } from "./request-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "./enlist-message-channel-listener-injection-token"; +import { enlistRequestChannelListenerInjectionToken } from "./enlist-request-channel-listener-injection-token"; + +const listeningOfChannelsInjectable = getInjectable({ + id: "listening-of-channels", + + instantiate: (di) => { + const enlistMessageChannelListener = di.inject(enlistMessageChannelListenerInjectionToken); + const enlistRequestChannelListener = di.inject(enlistRequestChannelListenerInjectionToken); + const messageChannelListeners = di.injectMany(messageChannelListenerInjectionToken); + const requestChannelListeners = di.injectMany(requestChannelListenerInjectionToken); + + return getStartableStoppable("listening-of-channels", () => { + const messageChannelDisposers = messageChannelListeners.map(enlistMessageChannelListener); + const requestChannelDisposers = requestChannelListeners.map(enlistRequestChannelListener); + + return disposer(...messageChannelDisposers, ...requestChannelDisposers); + }); + }, +}); + + +export default listeningOfChannelsInjectable; diff --git a/src/common/utils/channel/message-channel-injection-token.ts b/src/common/utils/channel/message-channel-injection-token.ts new file mode 100644 index 0000000000..3141acedf3 --- /dev/null +++ b/src/common/utils/channel/message-channel-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { JsonValue } from "type-fest"; + +export interface MessageChannel { + id: string; + _messageSignature?: Message; +} + +export const messageChannelInjectionToken = getInjectionToken>({ + id: "message-channel", +}); diff --git a/src/common/utils/channel/message-channel-listener-injection-token.ts b/src/common/utils/channel/message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..8879e19013 --- /dev/null +++ b/src/common/utils/channel/message-channel-listener-injection-token.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { MessageChannel } from "./message-channel-injection-token"; + +export interface MessageChannelListener> { + channel: TChannel; + handler: (value: SetRequired["_messageSignature"]) => void; +} + +export const messageChannelListenerInjectionToken = getInjectionToken>>( + { + id: "message-channel-listener", + }, +); diff --git a/src/common/utils/channel/message-to-channel-injection-token.ts b/src/common/utils/channel/message-to-channel-injection-token.ts new file mode 100644 index 0000000000..8c5f03b9ee --- /dev/null +++ b/src/common/utils/channel/message-to-channel-injection-token.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { MessageChannel } from "./message-channel-injection-token"; + +export interface MessageToChannel { + , TMessage extends void>( + channel: TChannel, + ): void; + + >( + channel: TChannel, + message: SetRequired["_messageSignature"], + ): void; +} + +export const messageToChannelInjectionToken = + getInjectionToken({ + id: "message-to-message-channel", + }); diff --git a/src/common/utils/channel/request-channel-injection-token.ts b/src/common/utils/channel/request-channel-injection-token.ts new file mode 100644 index 0000000000..67044db878 --- /dev/null +++ b/src/common/utils/channel/request-channel-injection-token.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { JsonValue } from "type-fest"; + +export interface RequestChannel< + Request extends JsonValue | void = void, + Response extends JsonValue | void = void, +> { + id: string; + _requestSignature?: Request; + _responseSignature?: Response; +} + +export const requestChannelInjectionToken = getInjectionToken>({ + id: "request-channel", +}); diff --git a/src/common/utils/channel/request-channel-listener-injection-token.ts b/src/common/utils/channel/request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..690b96d9dc --- /dev/null +++ b/src/common/utils/channel/request-channel-listener-injection-token.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { RequestChannel } from "./request-channel-injection-token"; + +export interface RequestChannelListener> { + channel: TChannel; + + handler: ( + request: SetRequired["_requestSignature"] + ) => + | SetRequired["_responseSignature"] + | Promise< + SetRequired["_responseSignature"] + >; +} + +export const requestChannelListenerInjectionToken = getInjectionToken>>( + { + id: "request-channel-listener", + }, +); diff --git a/src/common/utils/channel/request-from-channel-injection-token.ts b/src/common/utils/channel/request-from-channel-injection-token.ts new file mode 100644 index 0000000000..5f4492543f --- /dev/null +++ b/src/common/utils/channel/request-from-channel-injection-token.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { RequestChannel } from "./request-channel-injection-token"; + +export type RequestFromChannel = < + TChannel extends RequestChannel, +>( + channel: TChannel, + ...request: TChannel["_requestSignature"] extends void + ? [] + : [TChannel["_requestSignature"]] +) => Promise["_responseSignature"]>; + +export const requestFromChannelInjectionToken = + getInjectionToken({ + id: "request-from-request-channel", + }); diff --git a/src/common/utils/cluster-id-url-parsing.ts b/src/common/utils/cluster-id-url-parsing.ts index ded15d0472..cb02f36484 100644 --- a/src/common/utils/cluster-id-url-parsing.ts +++ b/src/common/utils/cluster-id-url-parsing.ts @@ -25,24 +25,3 @@ export function getClusterIdFromHost(host: string): ClusterId | undefined { export function getClusterFrameUrl(clusterId: ClusterId) { return `//${clusterId}.${location.host}`; } - -/** - * Get the result of `getClusterIdFromHost` from the current `location.host` - */ -export function getHostedClusterId(): ClusterId | undefined { - // catch being called in main - if (typeof location === "undefined") { - return undefined; - } - - return getClusterIdFromHost(location.host); -} - -/** - * Returns true only if code is running within a cluster iframe context - */ -export function isClusterPageContext(): boolean { - if (typeof window === "undefined") return false; - - return !!getClusterIdFromHost(window.location.host); -} diff --git a/src/common/utils/collection-functions.ts b/src/common/utils/collection-functions.ts index 76646601eb..0702230464 100644 --- a/src/common/utils/collection-functions.ts +++ b/src/common/utils/collection-functions.ts @@ -4,6 +4,8 @@ */ import { runInAction } from "mobx"; +import { inspect } from "util"; +import { isDefined } from "./type-narrowing"; /** * Get the value behind `key`. If it was not present, first insert `value` @@ -17,7 +19,17 @@ export function getOrInsert(map: Map, key: K, value: V): V { map.set(key, value); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; +} + +/** + * Updates map and returns the value that was just inserted + */ +export function put(map: Map, key: K, value: V): V { + map.set(key, value); + + return value; } /** @@ -45,7 +57,8 @@ export function getOrInsertWith(map: Map, key: K, builder: () => V): map.set(key, builder()); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; } /** @@ -56,7 +69,8 @@ export async function getOrInsertWithAsync(map: Map, key: K, asyncBu map.set(key, await asyncBuilder()); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; } /** @@ -67,7 +81,7 @@ export async function getOrInsertWithAsync(map: Map, key: K, asyncBu */ export function strictSet(map: Map, key: K, val: V): typeof map { if (map.has(key)) { - throw new TypeError("Duplicate key in map"); + throw new TypeError(`Map already contains key: ${inspect(key)}`); } return map.set(key, val); @@ -80,10 +94,11 @@ export function strictSet(map: Map, key: K, val: V): typeof map { */ export function strictGet(map: Map, key: K): V { if (!map.has(key)) { - throw new TypeError("key not in map"); + throw new TypeError(`Map does not contains key: ${inspect(key)}`); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; } /** @@ -99,3 +114,10 @@ export function toggle(set: Set, key: K): void { } }); } + +/** + * A helper function to also check for defined-ness + */ +export function includes(src: T[], value: T | null | undefined): boolean { + return isDefined(value) && src.includes(value); +} diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts index a578160c3b..db71148404 100644 --- a/src/common/utils/disposer.ts +++ b/src/common/utils/disposer.ts @@ -11,7 +11,7 @@ interface Extendable { export type ExtendableDisposer = Disposer & Extendable; -export function disposer(...args: Disposer[]): ExtendableDisposer { +export function disposer(...args: (Disposer | undefined | null)[]): ExtendableDisposer { const res = () => { args.forEach(dispose => dispose?.()); args.length = 0; diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index 3ff60470d3..5f3e658aa6 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -4,6 +4,8 @@ */ import request from "request"; +import type { JsonValue } from "type-fest"; +import { parse } from "./json"; export interface DownloadFileOptions { url: string; @@ -41,11 +43,11 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) }; } -export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { +export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { const { promise, ...rest } = downloadFile(args); return { - promise: promise.then(res => JSON.parse(res.toString())), + promise: promise.then(res => parse(res.toString())), ...rest, }; } diff --git a/src/common/utils/environment-variables.injectable.ts b/src/common/utils/environment-variables.injectable.ts new file mode 100644 index 0000000000..897b349d56 --- /dev/null +++ b/src/common/utils/environment-variables.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const environmentVariablesInjectable = getInjectable({ + id: "environment-variables", + + instantiate: () => { + // IMPORTANT: The syntax needs to be exactly this in order to make environment variable values + // hard-coded at compile-time by Webpack. + const NODE_ENV = process.env.NODE_ENV; + const JEST_WORKER_ID = process.env.JEST_WORKER_ID; + const CICD = process.env.CICD; + + return { + // Compile-time environment variables + NODE_ENV, + JEST_WORKER_ID, + CICD, + + // Runtime environment variables + LENS_DISABLE_GPU: process.env.LENS_DISABLE_GPU, + }; + }, + + causesSideEffects: true, +}); + +export default environmentVariablesInjectable; diff --git a/src/renderer/components/+workloads-cronjobs/cron-jobs-store.injectable.ts b/src/common/utils/get-random-id.injectable.ts similarity index 54% rename from src/renderer/components/+workloads-cronjobs/cron-jobs-store.injectable.ts rename to src/common/utils/get-random-id.injectable.ts index a05ca0fc2a..3b96c50633 100644 --- a/src/renderer/components/+workloads-cronjobs/cron-jobs-store.injectable.ts +++ b/src/common/utils/get-random-id.injectable.ts @@ -3,12 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { cronJobStore } from "./cronjob.store"; +import { v4 as getRandomId } from "uuid"; -const cronJobsStoreInjectable = getInjectable({ - id: "cron-jobs-store", - instantiate: () => cronJobStore, +const getRandomIdInjectable = getInjectable({ + id: "get-random-id", + instantiate: () => getRandomId, causesSideEffects: true, }); -export default cronJobsStoreInjectable; +export default getRandomIdInjectable; diff --git a/src/common/utils/get-startable-stoppable.test.ts b/src/common/utils/get-startable-stoppable.test.ts new file mode 100644 index 0000000000..24b9e1862d --- /dev/null +++ b/src/common/utils/get-startable-stoppable.test.ts @@ -0,0 +1,235 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getStartableStoppable } from "./get-startable-stoppable"; +import { getPromiseStatus } from "../test-utils/get-promise-status"; +import { flushPromises } from "../test-utils/flush-promises"; + +describe("getStartableStoppable", () => { + let stopMock: AsyncFnMock<() => Promise>; + let startMock: AsyncFnMock<() => Promise<() => Promise>>; + let actual: { stop: () => Promise; start: () => Promise; started: boolean }; + + beforeEach(() => { + stopMock = asyncFn(); + startMock = asyncFn(); + + actual = getStartableStoppable("some-id", startMock); + }); + + it("does not start yet", () => { + expect(startMock).not.toHaveBeenCalled(); + }); + + it("does not stop yet", () => { + expect(stopMock).not.toHaveBeenCalled(); + }); + + it("when stopping before ever starting, throws", () => { + expect(actual.stop).rejects.toThrow("Tried to stop \"some-id\", but it has not started yet."); + }); + + it("is not started", () => { + expect(actual.started).toBe(false); + }); + + describe("when started", () => { + let startPromise: Promise; + + beforeEach(() => { + startPromise = actual.start(); + }); + + it("starts starting", () => { + expect(startMock).toHaveBeenCalled(); + }); + + it("starting does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(startPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("is not started yet", () => { + expect(actual.started).toBe(false); + }); + + describe("when started again before the start has finished", () => { + let error: Error; + + beforeEach(() => { + startMock.mockClear(); + + actual.start().catch((e) => { error = e; }); + }); + + it("does not start starting again", () => { + expect(startMock).not.toHaveBeenCalled(); + }); + + it("throws", () => { + expect(error.message).toBe("Tried to start \"some-id\", but it is already being started."); + }); + }); + + describe("when starting finishes", () => { + beforeEach(async () => { + await startMock.resolve(stopMock); + }); + + it("is started", () => { + expect(actual.started).toBe(true); + }); + + it("starting resolves", async () => { + const promiseStatus = await getPromiseStatus(startPromise); + + expect(promiseStatus.fulfilled).toBe(true); + }); + + it("when started again, throws", () => { + expect(actual.start).rejects.toThrow("Tried to start \"some-id\", but it has already started."); + }); + + it("does not stop yet", () => { + expect(stopMock).not.toHaveBeenCalled(); + }); + + describe("when stopped", () => { + let stopPromise: Promise; + + beforeEach(() => { + stopPromise = actual.stop(); + }); + + it("starts stopping", () => { + expect(stopMock).toHaveBeenCalled(); + }); + + it("stopping does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(stopPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("is not stopped yet", () => { + expect(actual.started).toBe(true); + }); + + describe("when stopping finishes", () => { + beforeEach(async () => { + await stopMock.resolve(); + }); + + it("is not started", () => { + expect(actual.started).toBe(false); + }); + + it("stopping resolves", async () => { + const promiseStatus = await getPromiseStatus(stopPromise); + + expect(promiseStatus.fulfilled).toBe(true); + }); + + it("when stopped again, throws", () => { + expect(actual.stop).rejects.toThrow("Tried to stop \"some-id\", but it has already stopped."); + }); + + describe("when started again", () => { + beforeEach( + () => { + startMock.mockClear(); + + actual.start(); + }); + + it("starts", () => { + expect(startMock).toHaveBeenCalled(); + }); + + it("is not started yet", () => { + expect(actual.started).toBe(false); + }); + + describe("when starting finishes", () => { + beforeEach(async () => { + await startMock.resolve(stopMock); + }); + + it("is started", () => { + expect(actual.started).toBe(true); + }); + + it("when stopped again, starts stopping again", async () => { + stopMock.mockClear(); + + actual.stop(); + + await flushPromises(); + + expect(stopMock).toHaveBeenCalled(); + }); + }); + }); + }); + }); + }); + + describe("when stopped before starting finishes", () => { + let stopPromise: Promise; + + beforeEach(() => { + stopPromise = actual.stop(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(stopPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("is not started yet", () => { + expect(actual.started).toBe(false); + }); + + describe("when starting finishes", () => { + beforeEach(async () => { + await startMock.resolve(stopMock); + }); + + it("starts stopping", () => { + expect(stopMock).toHaveBeenCalled(); + }); + + it("is not stopped yet", () => { + expect(actual.started).toBe(true); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(stopPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when stopping finishes", () => { + beforeEach(async () => { + await stopMock.resolve(); + }); + + it("is stopped", () => { + expect(actual.started).toBe(false); + }); + + it("resolves", async () => { + const promiseStatus = await getPromiseStatus(stopPromise); + + expect(promiseStatus.fulfilled).toBe(true); + }); + }); + }); + }); + }); +}); diff --git a/src/common/utils/get-startable-stoppable.ts b/src/common/utils/get-startable-stoppable.ts new file mode 100644 index 0000000000..5590157ab8 --- /dev/null +++ b/src/common/utils/get-startable-stoppable.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +type Stopper = () => Promise | void; +type Starter = () => Promise | Stopper; + +export const getStartableStoppable = ( + id: string, + startAndGetStopCallback: Starter, +) => { + let stop: Stopper; + let stopped = false; + let started = false; + let starting = false; + let startingPromise: Promise | Stopper; + + return { + get started() { + return started; + }, + + start: async () => { + if (starting) { + throw new Error(`Tried to start "${id}", but it is already being started.`); + } + + starting = true; + + if (started) { + throw new Error(`Tried to start "${id}", but it has already started.`); + } + + startingPromise = startAndGetStopCallback(); + stop = await startingPromise; + + stopped = false; + started = true; + starting = false; + }, + + stop: async () => { + await startingPromise; + + if (stopped) { + throw new Error(`Tried to stop "${id}", but it has already stopped.`); + } + + if (!started) { + throw new Error(`Tried to stop "${id}", but it has not started yet.`); + } + + await stop(); + + started = false; + stopped = true; + }, + }; +}; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index cda7cf9eb2..e36ccd317f 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -14,7 +14,6 @@ export * from "./abort-controller"; export * from "./app-version"; export * from "./autobind"; export * from "./camelCase"; -export * from "./cloneJson"; export * from "./cluster-id-url-parsing"; export * from "./collection-functions"; export * from "./convertCpu"; @@ -28,7 +27,6 @@ export * from "./formatDuration"; export * from "./getRandId"; export * from "./hash-set"; export * from "./n-fircate"; -export * from "./objects"; export * from "./openBrowser"; export * from "./paths"; export * from "./promise-exec"; @@ -40,7 +38,9 @@ export * from "./splitArray"; export * from "./tar"; export * from "./toJS"; export * from "./type-narrowing"; +export * from "./types"; export * from "./wait-for-path"; +export * from "./wait"; export type { Tuple } from "./tuple"; @@ -48,10 +48,14 @@ import * as iter from "./iter"; import * as array from "./array"; import * as tuple from "./tuple"; import * as base64 from "./base64"; +import * as object from "./objects"; +import * as json from "./json"; export { iter, array, tuple, base64, + object, + json, }; diff --git a/src/common/utils/is-allowed-resource.injectable.ts b/src/common/utils/is-allowed-resource.injectable.ts index f66029c018..67050a7f02 100644 --- a/src/common/utils/is-allowed-resource.injectable.ts +++ b/src/common/utils/is-allowed-resource.injectable.ts @@ -12,14 +12,14 @@ export type IsAllowedResource = (resource: KubeResource) => boolean; const isAllowedResourceInjectable = getInjectable({ id: "is-allowed-resource", - instantiate: (di, resourceName: KubeResource) => { + instantiate: (di, resourceName: string) => { const allowedResources = di.inject(allowedResourcesInjectable); return computed(() => allowedResources.get().has(resourceName)); }, lifecycle: lifecycleEnum.keyedSingleton({ - getInstanceKey: (di, resource: KubeResource) => resource, + getInstanceKey: (di, resource: string) => resource, }), }); diff --git a/src/common/utils/is-promise/is-promise.test.ts b/src/common/utils/is-promise/is-promise.test.ts new file mode 100644 index 0000000000..565f272ed6 --- /dev/null +++ b/src/common/utils/is-promise/is-promise.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { isPromise } from "./is-promise"; + +describe("isPromise", () => { + it("given promise, returns true", () => { + const actual = isPromise(new Promise(() => {})); + + expect(actual).toBe(true); + }); + + it("given non-promise, returns false", () => { + const actual = isPromise({}); + + expect(actual).toBe(false); + }); + + it("given thenable, returns false", () => { + const actual = isPromise({ then: () => {} }); + + expect(actual).toBe(false); + }); + + it("given nothing, returns false", () => { + const actual = isPromise(undefined); + + expect(actual).toBe(false); + }); +}); diff --git a/src/common/utils/is-promise/is-promise.ts b/src/common/utils/is-promise/is-promise.ts new file mode 100644 index 0000000000..6261f569cd --- /dev/null +++ b/src/common/utils/is-promise/is-promise.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export function isPromise(reference: any): reference is Promise { + return reference?.constructor === Promise; +} diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index e1a9f41724..05d097ed78 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -5,14 +5,31 @@ export type Falsey = false | 0 | "" | null | undefined; +interface Iterator { + filter(fn: (val: T) => unknown): Iterator; + filterMap(fn: (val: T) => Falsey | U): Iterator; + find(fn: (val: T) => unknown): T | undefined; + collect(fn: (values: Iterable) => U): U; + map(fn: (val: T) => U): Iterator; + join(sep?: string): string; +} + +export function pipeline(src: IterableIterator): Iterator { + return { + filter: (fn) => pipeline(filter(src, fn)), + filterMap: (fn) => pipeline(filterMap(src, fn)), + map: (fn) => pipeline(map(src, fn)), + find: (fn) => find(src, fn), + join: (sep) => join(src, sep), + collect: (fn) => fn(src), + }; +} + /** * Create a new type safe empty Iterable * @returns An `Iterable` that yields 0 items */ -// eslint-disable-next-line require-yield -export function* newEmpty(): IterableIterator { - return; -} +export function* newEmpty(): IterableIterator {} /** * Creates a new `Iterable` that yields at most n items from src. @@ -167,25 +184,38 @@ export function reduce(src: Iterable, reducer: (acc: R, cur: T) => * @param connector The string value to intersperse between the yielded values * @returns The concatenated entries of `src` interspersed with copies of `connector` */ -export function join(src: Iterable, connector = ","): string { - return reduce(src, (acc, cur) => `${acc}${connector}${cur}`, ""); +export function join(src: IterableIterator, connector = ","): string { + const iterSrc = src[Symbol.iterator](); + const first = iterSrc.next(); + + if (first.done === true) { + return ""; + } + + return reduce(iterSrc, (acc, cur) => `${acc}${connector}${cur}`, `${first.value}`); } /** - * Returns the next value after iterating over the iterable `index` times. - * - * For example: `nth(["a", "b"], 0)` will return `"a"` - * For example: `nth(["a", "b"], 1)` will return `"b"` - * For example: `nth(["a", "b"], 2)` will return `undefined` + * Iterate `n` times and then return the next value. + * @param src The value to iterate over + * @param n The zero-index value for the item to return to. */ -export function nth(src: Iterable, index: number): T | undefined { - const iteree = src[Symbol.iterator](); +export function nth(src: Iterable, n: number): T | undefined { + const iterator = src[Symbol.iterator](); - while (index-- > 0) { - iteree.next(); + while (n --> 0) { + iterator.next(); } - return iteree.next().value; + return iterator.next().value; +} + +/** + * A convenience function to get the first item of an iterator + * @param src The value to iterate over + */ +export function first(src: Iterable): T | undefined { + return nth(src, 0); } /** diff --git a/src/common/utils/json.ts b/src/common/utils/json.ts new file mode 100644 index 0000000000..53d357f05c --- /dev/null +++ b/src/common/utils/json.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { JsonValue } from "type-fest"; + +export function parse(input: string): JsonValue { + return JSON.parse(input); +} diff --git a/src/common/utils/lazy-initialized.ts b/src/common/utils/lazy-initialized.ts index 866999e208..62247873fe 100644 --- a/src/common/utils/lazy-initialized.ts +++ b/src/common/utils/lazy-initialized.ts @@ -23,7 +23,8 @@ export function lazyInitialized(builder: () => T): LazyInitialized { return { get() { if (called) { - return value; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return value!; } value = builder(); diff --git a/src/common/utils/objects.ts b/src/common/utils/objects.ts index 342d1b00a1..8fe95be46d 100644 --- a/src/common/utils/objects.ts +++ b/src/common/utils/objects.ts @@ -8,9 +8,22 @@ * be a specific subset */ export function fromEntries(entries: Iterable): Record { - return Object.fromEntries(entries) as { [k in Key]: T }; + return Object.fromEntries(entries) as Record; } -export function entries>(obj: T): [keyof T, T[keyof T]][] { - return Object.entries(obj); +export function keys(obj: Partial>): K[]; + +export function keys(obj: Record): K[] { + return Object.keys(obj) as K[]; +} + +export function entries(obj: Partial> | null | undefined): [K, V][]; +export function entries(obj: Record | null | undefined): [K, V][]; + +export function entries(obj: Record | null | undefined): [K, V][] { + if (obj && typeof obj == "object") { + return Object.entries(obj) as never; + } + + return [] as never; } diff --git a/src/common/utils/readableStream.ts b/src/common/utils/readableStream.ts index 750d4cc901..5fd49ba699 100644 --- a/src/common/utils/readableStream.ts +++ b/src/common/utils/readableStream.ts @@ -24,7 +24,7 @@ export class ReadableWebToNodeStream extends Readable { * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader */ private reader: ReadableStreamReader; - private pendingRead: Promise>; + private pendingRead?: Promise>; /** * diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index 5f1172b660..d553de52db 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -51,6 +51,8 @@ export class Singleton { * Default: `true` * @returns An instance of the child class */ + static getInstance(this: StaticThis, strict?: true): T; + static getInstance(this: StaticThis, strict: false): T | undefined; static getInstance(this: StaticThis, strict = true): T | undefined { if (!Singleton.instances.has(this) && strict) { throw new TypeError(`instance of ${this.name} is not created`); diff --git a/src/common/utils/sort-compare.ts b/src/common/utils/sort-compare.ts index f05379cabd..df4c59b961 100644 --- a/src/common/utils/sort-compare.ts +++ b/src/common/utils/sort-compare.ts @@ -51,7 +51,7 @@ export function sortCompare(left: T, right: T): Ordering { interface ChartVersion { version: string; - __version?: SemVer; + __version?: SemVer | null; } export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion): Ordering { @@ -74,13 +74,13 @@ export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion export function sortCharts(charts: RawHelmChart[]) { interface ExtendedHelmChart extends RawHelmChart { - __version: SemVer; + __version?: SemVer | null; } const chartsWithVersion = Array.from( iter.map( charts, - (chart => { + chart => { const __version = coerce(chart.version, { includePrerelease: true, loose: true }); if (!__version) { @@ -90,7 +90,7 @@ export function sortCharts(charts: RawHelmChart[]) { (chart as ExtendedHelmChart).__version = __version; return chart as ExtendedHelmChart; - }), + }, ), ); diff --git a/src/common/utils/sync-box/create-sync-box.injectable.ts b/src/common/utils/sync-box/create-sync-box.injectable.ts new file mode 100644 index 0000000000..2cf3de6a69 --- /dev/null +++ b/src/common/utils/sync-box/create-sync-box.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import syncBoxChannelInjectable from "./sync-box-channel.injectable"; +import { messageToChannelInjectionToken } from "../channel/message-to-channel-injection-token"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; +import type { SyncBox } from "./sync-box-injection-token"; + +const createSyncBoxInjectable = getInjectable({ + id: "create-sync-box", + + instantiate: (di) => { + const syncBoxChannel = di.inject(syncBoxChannelInjectable); + const messageToChannel = di.inject(messageToChannelInjectionToken); + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + + return (id: string, initialValue: TData): SyncBox => { + const state = getSyncBoxState(id); + + state.set(initialValue); + + return { + id, + + value: computed(() => state.get()), + + set: (value) => { + state.set(value); + + messageToChannel(syncBoxChannel, { id, value }); + }, + }; + }; + }, +}); + +export default createSyncBoxInjectable; + diff --git a/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts b/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts new file mode 100644 index 0000000000..b603c85997 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { SyncBoxChannel } from "./sync-box-channel.injectable"; +import syncBoxChannelInjectable from "./sync-box-channel.injectable"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; +import type { MessageChannelListener } from "../channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../channel/message-channel-listener-injection-token"; + +const syncBoxChannelListenerInjectable = getInjectable({ + id: "sync-box-channel-listener", + + instantiate: (di): MessageChannelListener => { + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + const channel = di.inject(syncBoxChannelInjectable); + + return { + channel, + + handler: ({ id, value }) => { + const target = getSyncBoxState(id); + + if (target) { + target.set(value); + } + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default syncBoxChannelListenerInjectable; diff --git a/src/common/utils/sync-box/sync-box-channel.injectable.ts b/src/common/utils/sync-box/sync-box-channel.injectable.ts new file mode 100644 index 0000000000..9389a99867 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MessageChannel } from "../channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../channel/message-channel-injection-token"; + +export type SyncBoxChannel = MessageChannel<{ id: string; value: any }>; + +const syncBoxChannelInjectable = getInjectable({ + id: "sync-box-channel", + + instantiate: (): SyncBoxChannel => ({ + id: "sync-box-channel", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default syncBoxChannelInjectable; diff --git a/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts b/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts new file mode 100644 index 0000000000..89374c3565 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { RequestChannel } from "../channel/request-channel-injection-token"; +import { requestChannelInjectionToken } from "../channel/request-channel-injection-token"; + +export type SyncBoxInitialValueChannel = RequestChannel< + void, + { id: string; value: any }[] +>; + +const syncBoxInitialValueChannelInjectable = getInjectable({ + id: "sync-box-initial-value-channel", + + instantiate: (): SyncBoxInitialValueChannel => ({ + id: "sync-box-initial-value-channel", + }), + + injectionToken: requestChannelInjectionToken, +}); + +export default syncBoxInitialValueChannelInjectable; diff --git a/src/common/utils/sync-box/sync-box-injection-token.ts b/src/common/utils/sync-box/sync-box-injection-token.ts new file mode 100644 index 0000000000..d35c7d5367 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-injection-token.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { JsonValue } from "type-fest"; + +export interface SyncBox { + id: string; + value: IComputedValue; + set: (value: TValue) => void; +} + +export const syncBoxInjectionToken = getInjectionToken>({ + id: "sync-box", +}); diff --git a/src/common/utils/sync-box/sync-box-state.injectable.ts b/src/common/utils/sync-box/sync-box-state.injectable.ts new file mode 100644 index 0000000000..e695833da4 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-state.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const syncBoxStateInjectable = getInjectable({ + id: "sync-box-state", + + instantiate: () => observable.box(), + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, id: string) => id, + }), +}); + +export default syncBoxStateInjectable; diff --git a/src/common/utils/sync-box/sync-box.test.ts b/src/common/utils/sync-box/sync-box.test.ts new file mode 100644 index 0000000000..2dccbd87a5 --- /dev/null +++ b/src/common/utils/sync-box/sync-box.test.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observe, runInAction } from "mobx"; +import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import createSyncBoxInjectable from "./create-sync-box.injectable"; +import { flushPromises } from "../../test-utils/flush-promises"; +import type { SyncBox } from "./sync-box-injection-token"; + +describe("sync-box", () => { + let applicationBuilder: ApplicationBuilder; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.dis.mainDi.register(someInjectable); + applicationBuilder.dis.rendererDi.register(someInjectable); + }); + + // TODO: Separate starting for main application and starting of window in application builder + xdescribe("given application is started, when value is set in main", () => { + let valueInMain: string; + let syncBoxInMain: SyncBox; + + beforeEach(async () => { + syncBoxInMain = applicationBuilder.dis.mainDi.inject(someInjectable); + + // await applicationBuilder.start(); + + observe(syncBoxInMain.value, ({ newValue }) => { + valueInMain = newValue as string; + }, true); + + runInAction(() => { + syncBoxInMain.set("some-value-from-main"); + }); + }); + + it("knows value in main", () => { + expect(valueInMain).toBe("some-value-from-main"); + }); + + describe("when window starts", () => { + let valueInRenderer: string; + let syncBoxInRenderer: SyncBox; + + beforeEach(() => { + // applicationBuilder.renderWindow() + + syncBoxInRenderer = applicationBuilder.dis.rendererDi.inject(someInjectable); + + observe(syncBoxInRenderer.value, ({ newValue }) => { + valueInRenderer = newValue as string; + }, true); + }); + + it("does not have the initial value yet", () => { + expect(valueInRenderer).toBe(undefined); + }); + + describe("when getting initial value resolves", () => { + beforeEach(async () => { + await flushPromises(); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-main"); + }); + + describe("when value is set from renderer", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + + describe("when value is set from renderer before getting initial value from main resolves", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + }); + + describe("when application starts with a window", () => { + let valueInRenderer: string; + let valueInMain: string; + let syncBoxInMain: SyncBox; + let syncBoxInRenderer: SyncBox; + + beforeEach(async () => { + syncBoxInMain = applicationBuilder.dis.mainDi.inject(someInjectable); + syncBoxInRenderer = applicationBuilder.dis.rendererDi.inject(someInjectable); + + await applicationBuilder.render(); + + observe(syncBoxInRenderer.value, ({ newValue }) => { + valueInRenderer = newValue as string; + }, true); + + observe(syncBoxInMain.value, ({ newValue }) => { + valueInMain = newValue as string; + }, true); + }); + + it("knows initial value in main", () => { + expect(valueInMain).toBe("some-initial-value"); + }); + + it("knows initial value in renderer", () => { + expect(valueInRenderer).toBe("some-initial-value"); + }); + + describe("when value is set from main", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInMain.set("some-value-from-main"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-main"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-main"); + }); + + describe("when value is set from renderer", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + }); +}); + +const someInjectable = getInjectable({ + id: "some-injectable", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("some-sync-box", "some-initial-value"); + }, +}); diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index a72f8b4a1a..d351ec2507 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -8,14 +8,26 @@ import type { ExtractOptions, FileStat } from "tar"; import tar from "tar"; import path from "path"; +import { parse } from "./json"; +import type { JsonValue } from "type-fest"; -export interface ReadFileFromTarOpts { +export type ReadFileFromTarOpts = { tarPath: string; filePath: string; - parseJson?: boolean; -} +} & ( + ParseJson extends true + ? { + parseJson: true; + } + : { + parseJson?: false; + } +); -export function readFileFromTar({ tarPath, filePath, parseJson }: ReadFileFromTarOpts): Promise { +export function readFileFromTar(opts: ReadFileFromTarOpts): Promise; +export function readFileFromTar(opts: ReadFileFromTarOpts): Promise; + +export function readFileFromTar({ tarPath, filePath, parseJson = false }: ReadFileFromTarOpts): Promise { return new Promise((resolve, reject) => { const fileChunks: Buffer[] = []; @@ -32,7 +44,7 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re }); entry.once("end", () => { const data = Buffer.concat(fileChunks); - const result = parseJson ? JSON.parse(data.toString("utf8")) : data; + const result = parseJson ? parse(data.toString("utf8")) : data; resolve(result); }); @@ -51,7 +63,7 @@ export async function listTarEntries(filePath: string): Promise { await tar.list({ file: filePath, onentry: (entry: FileStat) => { - entries.push(path.normalize(entry.path as any as string)); + entries.push(path.normalize(entry.path as unknown as string)); }, }); diff --git a/src/common/utils/tentative-parse-json.ts b/src/common/utils/tentative-parse-json.ts new file mode 100644 index 0000000000..a0cb089a74 --- /dev/null +++ b/src/common/utils/tentative-parse-json.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { defaultTo } from "lodash/fp"; +import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; + +export const tentativeParseJson = (toBeParsed: any) => pipeline( + toBeParsed, + withErrorSuppression(JSON.parse), + defaultTo(toBeParsed), +); + + diff --git a/src/common/utils/tentative-stringify-json.ts b/src/common/utils/tentative-stringify-json.ts new file mode 100644 index 0000000000..dc7206be7c --- /dev/null +++ b/src/common/utils/tentative-stringify-json.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { defaultTo } from "lodash/fp"; +import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; + +export const tentativeStringifyJson = (toBeParsed: any) => pipeline( + toBeParsed, + withErrorSuppression(JSON.stringify), + defaultTo(toBeParsed), +); + + diff --git a/src/common/utils/tuple.ts b/src/common/utils/tuple.ts index 019dafcffc..2714ec23fa 100644 --- a/src/common/utils/tuple.ts +++ b/src/common/utils/tuple.ts @@ -24,6 +24,9 @@ type TupleOfImpl = R["length"] extends * @yields A tuple of the next element from each of the sources * @returns The tuple of all the sources as soon as at least one of the sources is exausted */ +export function zip(src1: T[]): Iterator<[T], Tuple>; +export function zip(src1: T[], src2: T[]): Iterator<[T, T], Tuple>; + export function* zip(...sources: Tuple): Iterator, Tuple> { const maxSafeLength = Math.min(...sources.map(source => source.length)); diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index d03dfa6b9a..a138b2a0ec 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -3,6 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { ExecException, ExecFileException } from "child_process"; +import type { IncomingMessage } from "http"; + /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested @@ -38,7 +41,7 @@ export function hasTypedProperty(val * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) * @param isValid a function to check if the field (when present) is valid */ -export function hasOptionalProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { +export function hasOptionalTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { if (hasOwnProperty(val, key)) { return typeof val[key] === "undefined" || isValid(val[key]); } @@ -73,6 +76,29 @@ export function isString(val: unknown): val is string { return typeof val === "string"; } +/** + * checks if val is of type Buffer + * @param val the value to be checked + */export function isBuffer(val: unknown): val is Buffer { + return val instanceof Buffer; +} + +/** + * checks if val is of type number + * @param val the value to be checked + */ +export function isNumber(val: unknown): val is number { + return typeof val === "number"; +} + +/** + * checks if val is of type boolean + * @param val the value to be checked + */ +export function isBoolean(val: unknown): val is boolean { + return typeof val === "boolean"; +} + /** * checks if val is of type object and isn't null * @param val the value to be checked @@ -81,6 +107,20 @@ export function isObject(val: unknown): val is object { return typeof val === "object" && val !== null; } +/** + * checks if `val` is defined, useful for filtering out undefined values in a strict manner + */ +export function isDefined(val: T | undefined | null): val is T { + return val != null; +} + +/** + * Checks if the value in the second position is non-nullable + */ +export function hasDefinedTupleValue(pair: [K, V | undefined | null]): pair is [K, V] { + return pair[1] != null; +} + /** * Creates a new predicate function (with the same predicate) from `fn`. Such * that it can be called with just the value to be tested. @@ -92,3 +132,81 @@ export function isObject(val: unknown): val is object { export function bindPredicate(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T { return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); } + +export function hasDefiniteField(field: Field): (val: T) => val is T & { [f in Field]-?: NonNullable } { + return (val): val is T & { [f in Field]-?: NonNullable } => val[field] != null; +} + +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return isObject(error) + && hasOptionalTypedProperty(error, "code", isString) + && hasOptionalTypedProperty(error, "path", isString) + && hasOptionalTypedProperty(error, "syscall", isString) + && hasOptionalTypedProperty(error, "errno", isNumber) + && error instanceof Error; +} + +export function isExecException(error: unknown): error is ExecException { + return isObject(error) + && hasOptionalTypedProperty(error, "cmd", isString) + && hasOptionalTypedProperty(error, "killed", isBoolean) + && hasOptionalTypedProperty(error, "signal", isString) + && hasOptionalTypedProperty(error, "code", isNumber) + && error instanceof Error; +} + +export function isExecFileException(error: unknown): error is ExecFileException { + return isExecException(error) && isErrnoException(error); +} + +export type OutputFormat = "string" | "buffer"; +export type ComputeOutputFormat = Format extends "string" + ? string + : Format extends "buffer" + ? Buffer + : string | Buffer; + +export interface ChildProcessExecpetion extends ExecFileException { + stderr: ComputeOutputFormat; + stdout: ComputeOutputFormat; +} + +const isStringOrBuffer = (val: unknown): val is string | Buffer => isString(val) || isBuffer(val); + +export function isChildProcessError(error: unknown, format?: OutputFormat): error is ChildProcessExecpetion { + if (!isExecFileException(error)) { + return false; + } + + if (format === "string") { + return hasTypedProperty(error, "stderr", isString) + && hasTypedProperty(error, "stdout", isString); + } else if (format === "buffer") { + return hasTypedProperty(error, "stderr", isBuffer) + && hasTypedProperty(error, "stdout", isBuffer); + } else { + return hasTypedProperty(error, "stderr", isStringOrBuffer) + && hasTypedProperty(error, "stdout", isStringOrBuffer); + } +} + +export interface RequestLikeError extends Error { + statusCode?: number; + failed?: boolean; + timedOut?: boolean; + error?: string; + response?: IncomingMessage & { body?: any }; +} + +/** + * A type guard for checking if the error is similar in shape to a request package error + */ +export function isRequestError(error: unknown): error is RequestLikeError { + return isObject(error) + && hasOptionalTypedProperty(error, "statusCode", isNumber) + && hasOptionalTypedProperty(error, "failed", isBoolean) + && hasOptionalTypedProperty(error, "timedOut", isBoolean) + && hasOptionalTypedProperty(error, "error", isString) + && hasOptionalTypedProperty(error, "response", isObject) + && error instanceof Error; +} diff --git a/src/common/utils/types.ts b/src/common/utils/types.ts new file mode 100644 index 0000000000..32ebe14e33 --- /dev/null +++ b/src/common/utils/types.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { SetRequired } from "type-fest"; + +export type RemoveUndefinedFromValues = { + [P in keyof K]: NonNullable; +}; + +/** + * This type helps define which fields of some type will always be defined + */ +export type Defaulted = RemoveUndefinedFromValues>> & Omit; + +export type OptionVarient = { + type: Key; +} & Pick & { + [OtherKey in Exclude]?: undefined; +}; + +export type SingleOrMany = T | T[]; + +export type IfEquals = + (() => G extends T ? 1 : 2) extends + (() => G extends U ? 1 : 2) ? Y : N; + +export type MaybeSetRequired = Query extends true + ? SetRequired + : BaseType; diff --git a/src/common/utils/wait.ts b/src/common/utils/wait.ts new file mode 100644 index 0000000000..7bcbd1688b --- /dev/null +++ b/src/common/utils/wait.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { IComputedValue } from "mobx"; +import { runInAction, when } from "mobx"; +import type { Disposer } from "./disposer"; + +export async function waitUntilDefined(getter: (() => T | null | undefined) | IComputedValue, opts?: { timeout?: number }): Promise { + return new Promise((resolve, reject) => { + let res: T | null | undefined; + + when( + () => { + res = typeof getter === "function" + ? getter() + : getter.get(); + + if (res != null) { + resolve(res); + + return true; + } + + return false; + }, + () => {}, + { + onError: reject, + ...opts, + }, + ); + }); +} + +export function onceDefined(getter: () => T | null | undefined, action: (val: T) => void): Disposer { + let res: T | null | undefined; + + return when( + () => { + res = getter(); + + if (res != null) { + const r = res; + + runInAction(() => { + action(r); + }); + + return true; + } + + return false; + }, + () => {}, + ); +} diff --git a/src/common/utils/with-error-logging/with-error-logging.injectable.ts b/src/common/utils/with-error-logging/with-error-logging.injectable.ts new file mode 100644 index 0000000000..12b48c6204 --- /dev/null +++ b/src/common/utils/with-error-logging/with-error-logging.injectable.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../../logger.injectable"; +import { isPromise } from "../is-promise/is-promise"; + +export type WithErrorLoggingFor = ( + getErrorMessage: (error: unknown) => string +) => any>( + toBeDecorated: T +) => (...args: Parameters) => ReturnType; + +const withErrorLoggingInjectable = getInjectable({ + id: "with-error-logging", + + instantiate: (di): WithErrorLoggingFor => { + const logger = di.inject(loggerInjectable); + + return (getErrorMessage) => + (toBeDecorated) => + (...args) => { + try { + const returnValue = toBeDecorated(...args); + + if (isPromise(returnValue)) { + returnValue.catch((e) => { + const errorMessage = getErrorMessage(e); + + logger.error(errorMessage, e); + }); + } + + return returnValue; + } catch (e) { + const errorMessage = getErrorMessage(e); + + logger.error(errorMessage, e); + + throw e; + } + }; + }, +}); + +export default withErrorLoggingInjectable; diff --git a/src/common/utils/with-error-logging/with-error-logging.test.ts b/src/common/utils/with-error-logging/with-error-logging.test.ts new file mode 100644 index 0000000000..533374d9ad --- /dev/null +++ b/src/common/utils/with-error-logging/with-error-logging.test.ts @@ -0,0 +1,243 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import loggerInjectable from "../../logger.injectable"; +import type { Logger } from "../../logger"; +import withErrorLoggingInjectable from "./with-error-logging.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; + +describe("with-error-logging", () => { + describe("given decorated sync function", () => { + let loggerStub: Logger; + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => number | undefined; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + loggerStub = { + error: jest.fn(), + } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = jest.fn(); + + decorated = pipeline( + toBeDecorated, + withErrorLoggingFor((error: any) => `some-error-message-for-${error.message}`), + ); + }); + + describe("when function does not throw and returns value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => 42); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("returns the value", () => { + expect(returnValue).toBe(42); + }); + }); + + describe("when function does not throw and returns no value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => undefined); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let error: Error; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + try { + decorated("some-parameter", "some-other-parameter"); + } catch (e: any) { + error = e; + } + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("logs the error", () => { + expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("throws", () => { + expect(error.message).toBe("some-error"); + }); + }); + }); + + describe("given decorated async function", () => { + let loggerStub: Logger; + let decorated: (a: string, b: string) => Promise; + let toBeDecorated: AsyncFnMock; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + loggerStub = { + error: jest.fn(), + } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = asyncFn(); + + decorated = pipeline( + toBeDecorated, + + withErrorLoggingFor( + (error: any) => + `some-error-message-for-${error.message || error.someProperty}`, + ), + ); + }); + + describe("when called", () => { + let returnValuePromise: Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error yet", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when call rejects with error instance", () => { + let error: Error; + + beforeEach(async () => { + try { + await toBeDecorated.reject(new Error("some-error")); + await returnValuePromise; + } catch (e) { + error = e as Error; + } + }); + + it("logs the error", () => { + expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("rejects", () => { + return expect(() => returnValuePromise).rejects.toThrow("some-error"); + }); + }); + + describe("when call rejects with something else than error instance", () => { + let error: unknown; + + beforeEach(async () => { + try { + await toBeDecorated.reject({ someProperty: "some-rejection" }); + await returnValuePromise; + } catch (e) { + error = e; + } + }); + + it("logs the rejection", () => { + expect(loggerStub.error).toHaveBeenCalledWith( + "some-error-message-for-some-rejection", + error, + ); + }); + + it("rejects", () => { + return expect(() => returnValuePromise).rejects.toEqual({ someProperty: "some-rejection" }); + }); + }); + + describe("when call resolves with value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(42); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("resolves with the value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + + describe("when call resolves without value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(undefined); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("resolves without value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/src/common/utils/with-error-suppression/with-error-suppression.test.ts b/src/common/utils/with-error-suppression/with-error-suppression.test.ts new file mode 100644 index 0000000000..db4909fd55 --- /dev/null +++ b/src/common/utils/with-error-suppression/with-error-suppression.test.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; +import { withErrorSuppression } from "./with-error-suppression"; + +describe("with-error-suppression", () => { + describe("given decorated sync function", () => { + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => void; + + beforeEach(() => { + toBeDecorated = jest.fn(); + + decorated = withErrorSuppression(toBeDecorated); + }); + + describe("when function does not throw", () => { + let returnValue: void; + + beforeEach(() => { + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let returnValue: void; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + }); + + describe("given decorated async function", () => { + let decorated: (a: string, b: string) => Promise | Promise; + let toBeDecorated: AsyncFnMock<(a: string, b: string) => number>; + + beforeEach(() => { + toBeDecorated = asyncFn(); + + decorated = withErrorSuppression(toBeDecorated); + }); + + describe("when called", () => { + let returnValuePromise: Promise | Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when call rejects, resolves with nothing", async () => { + await toBeDecorated.reject(new Error("some-error")); + + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + + it("when call resolves, resolves with the value", async () => { + await toBeDecorated.resolve(42); + + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + }); +}); diff --git a/src/common/utils/with-error-suppression/with-error-suppression.ts b/src/common/utils/with-error-suppression/with-error-suppression.ts new file mode 100644 index 0000000000..657ed13c16 --- /dev/null +++ b/src/common/utils/with-error-suppression/with-error-suppression.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { noop } from "lodash/fp"; + +export function withErrorSuppression Promise>(toBeDecorated: TDecorated): (...args: Parameters) => ReturnType | Promise; +export function withErrorSuppression any>(toBeDecorated: TDecorated): (...args: Parameters) => ReturnType | void; + +export function withErrorSuppression(toBeDecorated: any) { + return (...args: any[]) => { + try { + const returnValue = toBeDecorated(...args); + + if (isPromise(returnValue)) { + return returnValue.catch(noop); + } + + return returnValue; + } catch (e) { + return undefined; + } + }; +} + +function isPromise(reference: any): reference is Promise { + return !!reference?.then; +} diff --git a/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts b/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts new file mode 100644 index 0000000000..42e6cb9a61 --- /dev/null +++ b/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import withErrorLoggingInjectable from "../with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const withOrphanPromiseInjectable = getInjectable({ + id: "with-orphan-promise", + + instantiate: (di) => { + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return Promise>(toBeDecorated: T) => + (...args: Parameters): void => { + const decorated = pipeline( + toBeDecorated, + withErrorLoggingFor(() => "Orphan promise rejection encountered"), + withErrorSuppression, + ); + + decorated(...args); + }; + }, +}); + +export default withOrphanPromiseInjectable; diff --git a/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts b/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts new file mode 100644 index 0000000000..cea88b2352 --- /dev/null +++ b/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import loggerInjectable from "../../logger.injectable"; +import type { Logger } from "../../logger"; +import withOrphanPromiseInjectable from "./with-orphan-promise.injectable"; + +describe("with orphan promise, when called", () => { + let toBeDecorated: AsyncFnMock<(arg1: string, arg2: string) => Promise>; + let actual: void; + let loggerStub: Logger; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + loggerStub = { error: jest.fn() } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withOrphanPromise = di.inject(withOrphanPromiseInjectable); + + toBeDecorated = asyncFn(); + + const decorated = withOrphanPromise(toBeDecorated); + + actual = decorated("some-argument", "some-other-argument"); + }); + + it("calls decorated with arguments", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-argument", "some-other-argument"); + }); + + it("given promise returned by decorated has not been fulfilled yet, already returns nothing", () => { + expect(actual).toBeUndefined(); + }); + + it("when decorated function resolves, nothing happens", async () => { + await toBeDecorated.resolve("irrelevant"); + // Note: there is no expect, test is here only for documentation. + }); + + describe("when decorated function rejects", () => { + beforeEach(async () => { + await toBeDecorated.reject("some-error"); + }); + + it("logs the rejection", () => { + expect(loggerStub.error).toHaveBeenCalledWith("Orphan promise rejection encountered", "some-error"); + }); + + it("nothing else happens", () => { + // Note: there is no expect, test is here only for documentation. + }); + }); +}); diff --git a/src/common/vars.ts b/src/common/vars.ts index 52c9a15156..e11e49a7a2 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -7,28 +7,57 @@ import path from "path"; import { SemVer } from "semver"; import packageInfo from "../../package.json"; +import type { ThemeId } from "../renderer/themes/store"; import { lazyInitialized } from "./utils/lazy-initialized"; +/** + * @deprecated Switch to using isMacInjectable + */ export const isMac = process.platform === "darwin"; + +/** + * @deprecated Switch to using isWindowsInjectable + */ export const isWindows = process.platform === "win32"; + +/** + * @deprecated Switch to using isLinuxInjectable + */ export const isLinux = process.platform === "linux"; + export const isDebugging = ["true", "1", "yes", "y", "on"].includes((process.env.DEBUG ?? "").toLowerCase()); export const isSnap = !!process.env.SNAP; -export const isProduction = process.env.NODE_ENV === "production"; -export const isTestEnv = !!process.env.JEST_WORKER_ID; -export const isDevelopment = !isTestEnv && !isProduction; -export const isPublishConfigured = Object.keys(packageInfo.build).includes("publish"); -export const integrationTestingArg = "--integration-testing"; -export const isIntegrationTesting = process.argv.includes(integrationTestingArg); +/** + * @deprecated Switch to using isTestEnvInjectable + */ +export const isTestEnv = !!process.env.JEST_WORKER_ID; + +/** + * @deprecated Switch to using isProductionInjectable + */ +export const isProduction = process.env.NODE_ENV === "production"; + +/** + * @deprecated Switch to using isDevelopmentInjectable + */ +export const isDevelopment = !isTestEnv && !isProduction; export const productName = packageInfo.productName; + +/** + * @deprecated Switch to using appNameInjectable + */ export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; + export const publicPath = "/build/" as string; -export const defaultTheme = "lens-dark" as string; +export const defaultThemeId: ThemeId = "lens-dark"; export const defaultFontSize = 12; export const defaultTerminalFontFamily = "RobotoMono"; export const defaultEditorFontFamily = "RobotoMono"; +/** + * @deprecated use `di.inject(normalizedPlatformInjectable)` instead + */ export const normalizedPlatform = (() => { switch (process.platform) { case "darwin": @@ -41,6 +70,9 @@ export const normalizedPlatform = (() => { throw new Error(`platform=${process.platform} is unsupported`); } })(); +/** + * @deprecated use `di.inject(bundledBinariesNormalizedArchInjectable)` instead + */ export const normalizedArch = (() => { switch (process.arch) { case "arm64": @@ -91,25 +123,9 @@ export const helmBinaryName = getBinaryName("helm"); */ export const helmBinaryPath = lazyInitialized(() => path.join(baseBinariesDir.get(), helmBinaryName)); -/** - * @deprecated for being explicit side effect. - */ -export const kubectlBinaryName = getBinaryName("kubectl"); - -/** - * @deprecated for being explicit side effect. - */ -export const kubectlBinaryPath = lazyInitialized(() => path.join(baseBinariesDir.get(), kubectlBinaryName)); -export const staticFilesDirectory = path.resolve( - !isProduction - ? process.cwd() - : process.resourcesPath, - "static", -); - // Apis -export const apiPrefix = "/api" as string; // local router apis -export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis +export const apiPrefix = "/api"; // local router apis +export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; diff --git a/src/common/vars/base-bundled-binaries-dir.injectable.ts b/src/common/vars/base-bundled-binaries-dir.injectable.ts new file mode 100644 index 0000000000..41f5a5e0a3 --- /dev/null +++ b/src/common/vars/base-bundled-binaries-dir.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; +import bundledBinariesNormalizedArchInjectable from "./bundled-binaries-normalized-arch.injectable"; +import bundledResourcesDirectoryInjectable from "./bundled-resources-dir.injectable"; + +const baseBundeledBinariesDirectoryInjectable = getInjectable({ + id: "base-bundeled-binaries-directory", + instantiate: (di) => path.join( + di.inject(bundledResourcesDirectoryInjectable), + di.inject(bundledBinariesNormalizedArchInjectable), + ), +}); + +export default baseBundeledBinariesDirectoryInjectable; diff --git a/src/common/vars/bundled-binaries-normalized-arch.injectable.ts b/src/common/vars/bundled-binaries-normalized-arch.injectable.ts new file mode 100644 index 0000000000..3c838d626c --- /dev/null +++ b/src/common/vars/bundled-binaries-normalized-arch.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const bundledBinariesNormalizedArchInjectable = getInjectable({ + id: "bundled-binaries-normalized-arch", + instantiate: () => { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + case "amd64": + return "x64"; + case "386": + case "x32": + case "ia32": + return "ia32"; + default: + throw new Error(`arch=${process.arch} is unsupported`); + } + }, + causesSideEffects: true, +}); + +export default bundledBinariesNormalizedArchInjectable; diff --git a/src/common/vars/bundled-resources-dir.injectable.ts b/src/common/vars/bundled-resources-dir.injectable.ts new file mode 100644 index 0000000000..a73ff98e78 --- /dev/null +++ b/src/common/vars/bundled-resources-dir.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; +import isProductionInjectable from "./is-production.injectable"; +import normalizedPlatformInjectable from "./normalized-platform.injectable"; + +const bundledResourcesDirectoryInjectable = getInjectable({ + id: "bundled-resources-directory", + instantiate: (di) => { + const isProduction = di.inject(isProductionInjectable); + const normalizedPlatform = di.inject(normalizedPlatformInjectable); + + return isProduction + ? process.resourcesPath + : path.join(process.cwd(), "binaries", "client", normalizedPlatform); + }, +}); + +export default bundledResourcesDirectoryInjectable; diff --git a/src/common/vars/is-development.injectable.ts b/src/common/vars/is-development.injectable.ts index a731ae5a9f..190d754d8d 100644 --- a/src/common/vars/is-development.injectable.ts +++ b/src/common/vars/is-development.injectable.ts @@ -3,11 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { isDevelopment } from "../vars"; +import isProductionInjectable from "./is-production.injectable"; +import isTestEnvInjectable from "./is-test-env.injectable"; const isDevelopmentInjectable = getInjectable({ id: "is-development", - instantiate: () => isDevelopment, + + instantiate: (di) => { + const isProduction = di.inject(isProductionInjectable); + const isTestEnv = di.inject(isTestEnvInjectable); + + return !isTestEnv && !isProduction; + }, }); export default isDevelopmentInjectable; diff --git a/src/common/vars/is-integration-testing.injectable.ts b/src/common/vars/is-integration-testing.injectable.ts new file mode 100644 index 0000000000..7d6d5ce24e --- /dev/null +++ b/src/common/vars/is-integration-testing.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import commandLineArgumentsInjectable from "../../main/utils/command-line-arguments.injectable"; + +const isIntegrationTestingInjectable = getInjectable({ + id: "is-integration-testing", + + instantiate: (di) => { + const commandLineArguments = di.inject(commandLineArgumentsInjectable); + + return commandLineArguments.includes("--integration-testing"); + }, +}); + +export default isIntegrationTestingInjectable; diff --git a/src/common/vars/is-linux.injectable.ts b/src/common/vars/is-linux.injectable.ts index dbd3436129..d84165fad5 100644 --- a/src/common/vars/is-linux.injectable.ts +++ b/src/common/vars/is-linux.injectable.ts @@ -3,12 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { isLinux } from "../vars"; +import platformInjectable from "./platform.injectable"; const isLinuxInjectable = getInjectable({ id: "is-linux", - instantiate: () => isLinux, - causesSideEffects: true, + + instantiate: (di) => { + const platform = di.inject(platformInjectable); + + return platform === "linux"; + }, }); export default isLinuxInjectable; diff --git a/src/common/vars/is-mac.injectable.ts b/src/common/vars/is-mac.injectable.ts index 6a956b2426..67a6fda286 100644 --- a/src/common/vars/is-mac.injectable.ts +++ b/src/common/vars/is-mac.injectable.ts @@ -3,12 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { isMac } from "../vars"; +import platformInjectable from "./platform.injectable"; const isMacInjectable = getInjectable({ id: "is-mac", - instantiate: () => isMac, - causesSideEffects: true, + + instantiate: (di) => { + const platform = di.inject(platformInjectable); + + return platform === "darwin"; + }, }); export default isMacInjectable; diff --git a/src/common/vars/is-production.injectable.ts b/src/common/vars/is-production.injectable.ts new file mode 100644 index 0000000000..085b091dfa --- /dev/null +++ b/src/common/vars/is-production.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import environmentVariablesInjectable from "../utils/environment-variables.injectable"; + +const isProductionInjectable = getInjectable({ + id: "is-production", + + instantiate: (di) => { + const { NODE_ENV: nodeEnv } = di.inject(environmentVariablesInjectable); + + return nodeEnv === "production"; + }, +}); + +export default isProductionInjectable; diff --git a/src/common/vars/is-test-env.injectable.ts b/src/common/vars/is-test-env.injectable.ts new file mode 100644 index 0000000000..85965d0098 --- /dev/null +++ b/src/common/vars/is-test-env.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import environmentVariablesInjectable from "../utils/environment-variables.injectable"; + +const isTestEnvInjectable = getInjectable({ + id: "is-test-env", + + instantiate: (di) => { + const { JEST_WORKER_ID: jestWorkerId } = di.inject(environmentVariablesInjectable); + + return !!jestWorkerId; + }, +}); + +export default isTestEnvInjectable; diff --git a/src/common/vars/is-windows.injectable.ts b/src/common/vars/is-windows.injectable.ts index 4b92b78a3b..8eb78dcb58 100644 --- a/src/common/vars/is-windows.injectable.ts +++ b/src/common/vars/is-windows.injectable.ts @@ -3,12 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { isWindows } from "../vars"; +import platformInjectable from "./platform.injectable"; const isWindowsInjectable = getInjectable({ id: "is-windows", - instantiate: () => isWindows, - causesSideEffects: true, + + instantiate: (di) => { + const platform = di.inject(platformInjectable); + + return platform === "win32"; + }, }); export default isWindowsInjectable; diff --git a/src/common/vars/lens-resources-dir.injectable.ts b/src/common/vars/lens-resources-dir.injectable.ts new file mode 100644 index 0000000000..c454afb005 --- /dev/null +++ b/src/common/vars/lens-resources-dir.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import isProductionInjectable from "./is-production.injectable"; + +const lensResourcesDirInjectable = getInjectable({ + id: "lens-resources-dir", + + instantiate: (di) => { + const isProduction = di.inject(isProductionInjectable); + + return !isProduction + ? process.cwd() + : process.resourcesPath; + }, + + causesSideEffects: true, +}); + +export default lensResourcesDirInjectable; diff --git a/src/common/vars/normalized-platform.injectable.ts b/src/common/vars/normalized-platform.injectable.ts new file mode 100644 index 0000000000..7177678407 --- /dev/null +++ b/src/common/vars/normalized-platform.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const normalizedPlatformInjectable = getInjectable({ + id: "normalized-platform", + instantiate: () => { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + throw new Error(`platform=${process.platform} is unsupported`); + } + }, + causesSideEffects: true, +}); + +export default normalizedPlatformInjectable; diff --git a/src/common/vars/package-json.injectable.ts b/src/common/vars/package-json.injectable.ts new file mode 100644 index 0000000000..fa132be518 --- /dev/null +++ b/src/common/vars/package-json.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import packageJson from "../../../package.json"; + +const packageJsonInjectable = getInjectable({ + id: "package-json", + instantiate: () => packageJson, + causesSideEffects: true, +}); + +export default packageJsonInjectable; diff --git a/src/renderer/components/+workloads-jobs/jobs-store.injectable.ts b/src/common/vars/platform.injectable.ts similarity index 57% rename from src/renderer/components/+workloads-jobs/jobs-store.injectable.ts rename to src/common/vars/platform.injectable.ts index 773706b8d6..11939a7f06 100644 --- a/src/renderer/components/+workloads-jobs/jobs-store.injectable.ts +++ b/src/common/vars/platform.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { jobStore } from "./job.store"; -const jobsStoreInjectable = getInjectable({ - id: "jobs-store", - instantiate: () => jobStore, +const platformInjectable = getInjectable({ + id: "platform", + instantiate: () => process.platform, causesSideEffects: true, }); -export default jobsStoreInjectable; +export default platformInjectable; diff --git a/src/common/vars/static-files-directory.injectable.ts b/src/common/vars/static-files-directory.injectable.ts new file mode 100644 index 0000000000..c881f12b97 --- /dev/null +++ b/src/common/vars/static-files-directory.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getAbsolutePathInjectable from "../path/get-absolute-path.injectable"; +import lensResourcesDirInjectable from "./lens-resources-dir.injectable"; + +const staticFilesDirectoryInjectable = getInjectable({ + id: "static-files-directory", + + instantiate: (di) => { + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const lensResourcesDir = di.inject(lensResourcesDirInjectable); + + return getAbsolutePath(lensResourcesDir, "static"); + }, +}); + +export default staticFilesDirectoryInjectable; diff --git a/src/common/weblink-store.injectable.ts b/src/common/weblink-store.injectable.ts new file mode 100644 index 0000000000..4aca7dce2a --- /dev/null +++ b/src/common/weblink-store.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { WeblinkStore } from "./weblink-store"; + +const weblinkStoreInjectable = getInjectable({ + id: "weblink-store", + + instantiate: () => { + WeblinkStore.resetInstance(); + + return WeblinkStore.createInstance(); + }, +}); + +export default weblinkStoreInjectable; diff --git a/src/extensions/__tests__/extension-compatibility.test.ts b/src/extensions/__tests__/extension-compatibility.test.ts deleted file mode 100644 index af9a28f59c..0000000000 --- a/src/extensions/__tests__/extension-compatibility.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { rawIsCompatibleExtension } from "../extension-compatibility"; -import { Console } from "console"; -import { stdout, stderr } from "process"; -import type { LensExtensionManifest } from "../lens-extension"; -import { SemVer } from "semver"; - -console = new Console(stdout, stderr); - -describe("extension compatibility", () => { - describe("appSemVer with no prerelease tag", () => { - const isCompatibleExtension = rawIsCompatibleExtension(new SemVer("5.0.3")); - - it("has no extension comparator", () => { - const manifest = { name: "extensionName", version: "0.0.1" }; - - expect(isCompatibleExtension(manifest)).toBe(false); - }); - - it.each([ - { - comparator: "", - expected: false, - }, - { - comparator: "bad comparator", - expected: false, - }, - { - comparator: "^4.0.0", - expected: false, - }, - { - comparator: "^5.0.0", - expected: true, - }, - { - comparator: "^6.0.0", - expected: false, - }, - { - comparator: "^4.0.0-alpha.1", - expected: false, - }, - { - comparator: "^5.0.0-alpha.1", - expected: true, - }, - { - comparator: "^6.0.0-alpha.1", - expected: false, - }, - ])("extension comparator test: %p", ({ comparator, expected }) => { - const manifest: LensExtensionManifest = { name: "extensionName", version: "0.0.1", engines: { lens: comparator }}; - - expect(isCompatibleExtension(manifest)).toBe(expected); - }); - }); - - describe("appSemVer with prerelease tag", () => { - const isCompatibleExtension = rawIsCompatibleExtension(new SemVer("5.0.3-beta.3")); - - it("^5.1.0 should work when lens' version is 5.1.0-latest.123456789", () => { - const comparer = rawIsCompatibleExtension(new SemVer("5.1.0-latest.123456789")); - - expect(comparer({ name: "extensionName", version: "0.0.1", engines: { lens: "^5.1.0" }})).toBe(true); - }); - - it("^5.1.0 should not when lens' version is 5.1.0-beta.1.123456789", () => { - const comparer = rawIsCompatibleExtension(new SemVer("5.1.0-beta.123456789")); - - expect(comparer({ name: "extensionName", version: "0.0.1", engines: { lens: "^5.1.0" }})).toBe(false); - }); - - it("^5.1.0 should not when lens' version is 5.1.0-alpha.1.123456789", () => { - const comparer = rawIsCompatibleExtension(new SemVer("5.1.0-alpha.123456789")); - - expect(comparer({ name: "extensionName", version: "0.0.1", engines: { lens: "^5.1.0" }})).toBe(false); - }); - - it("has no extension comparator", () => { - const manifest = { name: "extensionName", version: "0.0.1" }; - - expect(isCompatibleExtension(manifest)).toBe(false); - }); - - it.each([ - { - comparator: "", - expected: false, - }, - { - comparator: "bad comparator", - expected: false, - }, - { - comparator: "^4.0.0", - expected: false, - }, - { - comparator: "^5.0.0", - expected: true, - }, - { - comparator: "^6.0.0", - expected: false, - }, - { - comparator: "^4.0.0-alpha.1", - expected: false, - }, - { - comparator: "^5.0.0-alpha.1", - expected: true, - }, - { - comparator: "^6.0.0-alpha.1", - expected: false, - }, - ])("extension comparator test: %p", ({ comparator, expected }) => { - expect(isCompatibleExtension({ name: "extensionName", version: "0.0.1", engines: { lens: comparator }})).toBe(expected); - }); - }); -}); diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index c9e145f8f9..ae150e4f99 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -9,8 +9,8 @@ import { stdout, stderr } from "process"; import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; import { runInAction } from "mobx"; import updateExtensionsStateInjectable from "../extension-loader/update-extensions-state/update-extensions-state.injectable"; -import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; import mockFs from "mock-fs"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; console = new Console(stdout, stderr); @@ -109,18 +109,16 @@ describe("ExtensionLoader", () => { let extensionLoader: ExtensionLoader; let updateExtensionStateMock: jest.Mock; - beforeEach(async () => { - const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs(); updateExtensionStateMock = jest.fn(); - dis.mainDi.override(updateExtensionsStateInjectable, () => updateExtensionStateMock); + di.override(updateExtensionsStateInjectable, () => updateExtensionStateMock); - await dis.runSetups(); - - extensionLoader = dis.mainDi.inject(extensionLoaderInjectable); + extensionLoader = di.inject(extensionLoaderInjectable); }); afterEach(() => { diff --git a/src/extensions/__tests__/is-compatible-extension.test.ts b/src/extensions/__tests__/is-compatible-extension.test.ts new file mode 100644 index 0000000000..d581722139 --- /dev/null +++ b/src/extensions/__tests__/is-compatible-extension.test.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import assert from "assert"; +import semver from "semver"; +import { isCompatibleExtension } from "../extension-discovery/is-compatible-extension/is-compatible-extension"; +import type { LensExtensionManifest } from "../lens-extension"; + +describe("Extension/App versions compatibility checks", () => { + it("is compatible with exact version matching", () => { + expect(isCompatible({ extLensEngineVersion: "5.5.0", appVersion: "5.5.0" })).toBeTruthy(); + }); + + it("is compatible with upper %PATCH versions of base app", () => { + expect(isCompatible({ extLensEngineVersion: "5.5.0", appVersion: "5.5.5" })).toBeTruthy(); + }); + + it("is compatible with higher %MINOR version of base app", () => { + expect(isCompatible({ extLensEngineVersion: "5.5.0", appVersion: "5.6.0" })).toBeTruthy(); + }); + + it("is not compatible with higher %MAJOR version of base app", () => { + expect(isCompatible({ extLensEngineVersion: "5.6.0", appVersion: "6.0.0" })).toBeFalsy(); // extension for lens@5 not compatible with lens@6 + expect(isCompatible({ extLensEngineVersion: "6.0.0", appVersion: "5.6.0" })).toBeFalsy(); + }); + + it("is compatible with lensEngine with prerelease", () => { + expect(isCompatible({ + extLensEngineVersion: "^5.4.0-alpha.0", + appVersion: "5.5.0-alpha.0", + })).toBeTruthy(); + }); + + it("supports short version format for manifest.engines.lens", () => { + expect(isCompatible({ extLensEngineVersion: "5.5", appVersion: "5.5.1" })).toBeTruthy(); + }); + + it("throws for incorrect or not supported version format", () => { + expect(() => isCompatible({ + extLensEngineVersion: ">=2.0", + appVersion: "2.0", + })).toThrow(/Invalid format/i); + + expect(() => isCompatible({ + extLensEngineVersion: "~2.0", + appVersion: "2.0", + })).toThrow(/Invalid format/i); + + expect(() => isCompatible({ + extLensEngineVersion: "*", + appVersion: "1.0", + })).toThrow(/Invalid format/i); + }); +}); + +function isCompatible({ extLensEngineVersion = "^1.0", appVersion = "1.0" } = {}): boolean { + const appSemVer = semver.coerce(appVersion); + const extensionManifestMock = getExtensionManifestMock(extLensEngineVersion); + + assert(appSemVer); + + return isCompatibleExtension({ appSemVer })(extensionManifestMock); +} + +function getExtensionManifestMock(lensEngine = "1.0"): LensExtensionManifest { + return { + name: "some-extension", + version: "1.0", + engines: { + lens: lensEngine, + }, + }; +} diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index fe46d32afc..7cb90a548b 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -9,7 +9,7 @@ import { stdout, stderr } from "process"; console = new Console(stdout, stderr); -let ext: LensExtension = null; +let ext: LensExtension; describe("lens extension", () => { beforeEach(async () => { @@ -17,6 +17,7 @@ describe("lens extension", () => { manifest: { name: "foo-bar", version: "0.1.1", + engines: { lens: "^5.5.0" }, }, id: "/this/is/fake/package.json", absolutePath: "/absolute/fake/", diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts index 4ab374743c..e0b4ea3223 100644 --- a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts @@ -17,4 +17,4 @@ export const asLegacyGlobalFunctionForExtensionApi = (( ) as unknown as (...args: any[]) => any; return injected(...args); - }) as Inject; + }) as Inject; diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts index 270bb6091e..81d6f94b20 100644 --- a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts @@ -43,4 +43,4 @@ export const asLegacyGlobalForExtensionApi = (( return propertyValue; }, }, - )) as Inject; + )) as Inject; diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts index b9ed0f826e..f9016f045b 100644 --- a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts @@ -7,17 +7,23 @@ import { asLegacyGlobalForExtensionApi } from "./as-legacy-global-object-for-ext import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; import loggerInjectable from "../../common/logger.injectable"; -export const asLegacyGlobalSingletonForExtensionApi = < - Instance, - InstantiationParameter = void, ->( - injectable: Injectable, - instantiationParameter?: InstantiationParameter, - ) => { +export interface LegacySingleton { + createInstance(): T; + getInstance(): T; + resetInstance(): void; +} + +export function asLegacyGlobalSingletonForExtensionApi(injectable: Injectable): LegacySingleton; +export function asLegacyGlobalSingletonForExtensionApi(injectable: Injectable, param: InstantiationParameter): LegacySingleton; + +export function asLegacyGlobalSingletonForExtensionApi( + injectable: Injectable, + instantiationParameter?: InstantiationParameter, +): LegacySingleton { const instance = asLegacyGlobalForExtensionApi( - injectable, + injectable as never, instantiationParameter, - ); + ) as Instance; return { createInstance: () => instance, @@ -33,4 +39,4 @@ export const asLegacyGlobalSingletonForExtensionApi = < ); }, }; -}; +} diff --git a/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts index 4e4dfba0a8..4d7d9ca8f5 100644 --- a/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts +++ b/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts @@ -19,14 +19,25 @@ export const setLegacyGlobalDiForExtensionApi = ( }; export const getLegacyGlobalDiForExtensionApi = () => { - const globalDis = [...legacyGlobalDis.values()]; - - if (globalDis.length > 1) { + if (legacyGlobalDis.size > 1) { throw new Error("Tried to get DI container using legacy globals where there is multiple containers available."); } - return globalDis[0]; + const [di] = [...legacyGlobalDis.values()]; + + if (!di) { + throw new Error("Tried to get DI container using legacy globals where there is no containers available."); + } + + return di; }; -export const getEnvironmentSpecificLegacyGlobalDiForExtensionApi = (environment: Environments) => - legacyGlobalDis.get(environment); +export function getEnvironmentSpecificLegacyGlobalDiForExtensionApi(environment: Environments) { + const di = legacyGlobalDis.get(environment); + + if (!di) { + throw new Error("Tried to get DI container using legacy globals in environment which doesn't exist"); + } + + return di; +} diff --git a/src/extensions/common-api/catalog.ts b/src/extensions/common-api/catalog.ts index fae083e75a..b31059d2c1 100644 --- a/src/extensions/common-api/catalog.ts +++ b/src/extensions/common-api/catalog.ts @@ -3,13 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import kubernetesClusterCategoryInjectable from "../../common/catalog/categories/kubernetes-cluster.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + export { KubernetesCluster, - kubernetesClusterCategory, GeneralEntity, WebLink, } from "../../common/catalog-entities"; +export const kubernetesClusterCategory = asLegacyGlobalForExtensionApi(kubernetesClusterCategoryInjectable); + export type { KubernetesClusterPrometheusMetrics, KubernetesClusterSpec, diff --git a/src/extensions/common-api/k8s-api.ts b/src/extensions/common-api/k8s-api.ts new file mode 100644 index 0000000000..b79f4c92f4 --- /dev/null +++ b/src/extensions/common-api/k8s-api.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// NOTE: this file is not currently exported as part of `Common`, but should be. +// It is here to consolidate the common parts which are exported to `Main` +// and to `Renderer` + +export { ResourceStack } from "../../common/k8s/resource-stack"; +import apiManagerInjectable from "../../common/k8s-api/api-manager/manager.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +export const apiManager = asLegacyGlobalForExtensionApi(apiManagerInjectable); + +export { + KubeApi, + forCluster, + forRemoteCluster, + type ILocalKubeApiConfig, + type IRemoteKubeApiConfig, + type IKubeApiCluster, +} from "../../common/k8s-api/kube-api"; + +export { + KubeObject, + KubeStatus, + type KubeObjectMetadata, + type BaseKubeJsonApiObjectMetadata, + type KubeJsonApiObjectMetadata, + type KubeStatusData, +} from "../../common/k8s-api/kube-object"; + +export { + KubeObjectStore, + type KubeObjectStoreLoadAllParams, + type KubeObjectStoreLoadingParams, + type KubeObjectStoreSubscribeParams, +} from "../../common/k8s-api/kube-object.store"; + +export { + type PodContainer as IPodContainer, + type PodContainerStatus as IPodContainerStatus, + Pod, + PodApi as PodsApi, + Node, + NodeApi as NodesApi, + Deployment, + DeploymentApi, + DaemonSet, + StatefulSet, + Job, + CronJob, + ConfigMap, + type SecretReference as ISecretRef, + Secret, + ReplicaSet, + ResourceQuota, + LimitRange, + HorizontalPodAutoscaler, + PodDisruptionBudget, + Service, + Endpoints as Endpoint, + Ingress, IngressApi, + NetworkPolicy, + PersistentVolume, + PersistentVolumeClaim, + PersistentVolumeClaimApi as PersistentVolumeClaimsApi, + StorageClass, + Namespace, + KubeEvent, + ServiceAccount, + Role, + RoleBinding, + ClusterRole, + ClusterRoleBinding, + CustomResourceDefinition, +} from "../../common/k8s-api/endpoints"; diff --git a/src/extensions/extension-compatibility.ts b/src/extensions/extension-compatibility.ts deleted file mode 100644 index ab94a5ad43..0000000000 --- a/src/extensions/extension-compatibility.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import semver, { SemVer } from "semver"; -import { appSemVer, isProduction } from "../common/vars"; -import type { LensExtensionManifest } from "./lens-extension"; - -export function rawIsCompatibleExtension(version: SemVer): (manifest: LensExtensionManifest) => boolean { - const { major, minor, patch, prerelease: oldPrelease } = version; - let prerelease = ""; - - if (oldPrelease.length > 0) { - const [first] = oldPrelease; - - if (first === "alpha" || first === "beta" || first === "rc") { - /** - * Strip the build IDs and "latest" prerelease tag as that is not really - * a part of API version - */ - prerelease = `-${oldPrelease.slice(0, 2).join(".")}`; - } - } - - /** - * We unfortunately have to format as string because the constructor only - * takes an instance or a string. - */ - const strippedVersion = new SemVer(`${major}.${minor}.${patch}${prerelease}`, { includePrerelease: true }); - - return (manifest: LensExtensionManifest): boolean => { - if (manifest.engines?.lens) { - /** - * include Lens's prerelease tag in the matching so the extension's - * compatibility is not limited by it - */ - return semver.satisfies(strippedVersion, manifest.engines.lens, { includePrerelease: true }); - } - - return false; - }; -} - -export const isCompatibleExtension = rawIsCompatibleExtension(appSemVer); - -export function isCompatibleBundledExtension(manifest: LensExtensionManifest): boolean { - return !isProduction || manifest.version === appSemVer.raw; -} diff --git a/src/extensions/extension-discovery/extension-discovery.injectable.ts b/src/extensions/extension-discovery/extension-discovery.injectable.ts index 6833dda3a8..f7ffda8a80 100644 --- a/src/extensions/extension-discovery/extension-discovery.injectable.ts +++ b/src/extensions/extension-discovery/extension-discovery.injectable.ts @@ -12,6 +12,7 @@ import extensionInstallationStateStoreInjectable from "../extension-installation import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/extension-package-root-directory.injectable"; import installExtensionsInjectable from "../extension-installer/install-extensions/install-extensions.injectable"; +import staticFilesDirectoryInjectable from "../../common/vars/static-files-directory.injectable"; const extensionDiscoveryInjectable = getInjectable({ id: "extension-discovery", @@ -37,6 +38,8 @@ const extensionDiscoveryInjectable = getInjectable({ extensionPackageRootDirectory: di.inject( extensionPackageRootDirectoryInjectable, ), + + staticFilesDirectory: di.inject(staticFilesDirectoryInjectable), }), }); diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts index 84737a50d2..4e71f5ff7c 100644 --- a/src/extensions/extension-discovery/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { FSWatcher } from "chokidar"; import { watch } from "chokidar"; import path from "path"; import os from "os"; @@ -48,7 +49,7 @@ const mockedFse = fse as jest.Mocked; describe("ExtensionDiscovery", () => { let extensionDiscovery: ExtensionDiscovery; - beforeEach(async () => { + beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); @@ -56,8 +57,6 @@ describe("ExtensionDiscovery", () => { mockFs(); - await di.runSetups(); - extensionDiscovery = di.inject(extensionDiscoveryInjectable); }); @@ -66,7 +65,7 @@ describe("ExtensionDiscovery", () => { }); it("emits add for added extension", async (done) => { - let addHandler: (filePath: string) => void; + let addHandler!: (filePath: string) => void; mockedFse.readJson.mockImplementation((p) => { expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json")); @@ -74,12 +73,15 @@ describe("ExtensionDiscovery", () => { return { name: "my-extension", version: "1.0.0", + engines: { + lens: "5.0.0", + }, }; }); mockedFse.pathExists.mockImplementation(() => true); - const mockWatchInstance: any = { + const mockWatchInstance = { on: jest.fn((event: string, handler: typeof addHandler) => { if (event === "add") { addHandler = handler; @@ -87,11 +89,9 @@ describe("ExtensionDiscovery", () => { return mockWatchInstance; }), - }; + } as unknown as FSWatcher; - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, - ); + mockedWatch.mockImplementationOnce(() => mockWatchInstance); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; @@ -104,10 +104,13 @@ describe("ExtensionDiscovery", () => { id: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), isBundled: false, isEnabled: false, - isCompatible: false, + isCompatible: true, manifest: { name: "my-extension", version: "1.0.0", + engines: { + lens: "5.0.0", + }, }, manifestPath: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), }); @@ -118,9 +121,9 @@ describe("ExtensionDiscovery", () => { }); it("doesn't emit add for added file under extension", async done => { - let addHandler: (filePath: string) => void; + let addHandler!: (filePath: string) => void; - const mockWatchInstance: any = { + const mockWatchInstance = { on: jest.fn((event: string, handler: typeof addHandler) => { if (event === "add") { addHandler = handler; @@ -128,11 +131,9 @@ describe("ExtensionDiscovery", () => { return mockWatchInstance; }), - }; + } as unknown as FSWatcher; - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, - ); + mockedWatch.mockImplementationOnce(() => mockWatchInstance); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts index 846a13f17e..3576e66362 100644 --- a/src/extensions/extension-discovery/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -11,12 +11,12 @@ import { makeObservable, observable, reaction, when } from "mobx"; import os from "os"; import path from "path"; import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; -import { toJS } from "../../common/utils"; +import { isErrnoException, toJS } from "../../common/utils"; import logger from "../../main/logger"; import type { ExtensionsStore } from "../extensions-store/extensions-store"; import type { ExtensionLoader } from "../extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; -import { isProduction, staticFilesDirectory } from "../../common/vars"; +import { isProduction } from "../../common/vars"; import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; import type { PackageJson } from "type-fest"; import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; @@ -34,6 +34,7 @@ interface Dependencies { installExtension: (name: string) => Promise; installExtensions: (packageJsonPath: string, packagesJson: PackageJson) => Promise; extensionPackageRootDirectory: string; + staticFilesDirectory: string; } export interface InstalledExtension { @@ -81,7 +82,7 @@ interface LoadFromFolderOptions { * - "remove": When extension is removed. The event is of type LensExtensionId */ export class ExtensionDiscovery { - protected bundledFolderPath: string; + protected bundledFolderPath!: string; private loadStarted = false; private extensions: Map = new Map(); @@ -112,7 +113,7 @@ export class ExtensionDiscovery { } get inTreeFolderPath(): string { - return path.resolve(staticFilesDirectory, "../extensions"); + return path.resolve(this.dependencies.staticFilesDirectory, "../extensions"); } get nodeModulesPath(): string { @@ -271,7 +272,13 @@ export class ExtensionDiscovery { * @param extensionId The ID of the extension to uninstall. */ async uninstallExtension(extensionId: LensExtensionId): Promise { - const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); + const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); + + if (!extension) { + return void logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId }); + } + + const { manifest, absolutePath } = extension; logger.info(`${logModule} Uninstalling ${manifest.name}`); @@ -369,7 +376,7 @@ export class ExtensionDiscovery { isCompatible, }; } catch (error) { - if (error.code === "ENOTDIR") { + if (isErrnoException(error) && error.code === "ENOTDIR") { // ignore this error, probably from .DS_Store file logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`); } else { @@ -392,10 +399,12 @@ export class ExtensionDiscovery { try { await this.dependencies.installExtension(extension.absolutePath); } catch (error) { - const message = error.message || error || "unknown error"; + const message = error instanceof Error + ? error.message + : String(error || "unknown error"); const { name, version } = extension.manifest; - logger.error(`${logModule}: failed to install user extension ${name}@${version}`, message); + logger.error(`${logModule}: failed to install user extension ${name}@${version}: ${message}`); } } } diff --git a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts index d8f72d4f9f..44b0f1782d 100644 --- a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts +++ b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts @@ -2,51 +2,38 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import semver, { SemVer } from "semver"; +import semver, { type SemVer } from "semver"; import type { LensExtensionManifest } from "../../lens-extension"; interface Dependencies { appSemVer: SemVer; } -export const isCompatibleExtension = ({ - appSemVer, -}: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { - const { major, minor, patch, prerelease: oldPrelease } = appSemVer; - let prerelease = ""; - - if (oldPrelease.length > 0) { - const [first] = oldPrelease; - - if (first === "alpha" || first === "beta" || first === "rc") { - /** - * Strip the build IDs and "latest" prerelease tag as that is not really - * a part of API version - */ - prerelease = `-${oldPrelease.slice(0, 2).join(".")}`; - } - } - - /** - * We unfortunately have to format as string because the constructor only - * takes an instance or a string. - */ - const strippedVersion = new SemVer( - `${major}.${minor}.${patch}${prerelease}`, - { includePrerelease: true }, - ); - +export const isCompatibleExtension = ({ appSemVer }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { return (manifest: LensExtensionManifest): boolean => { - if (manifest.engines?.lens) { - /** - * include Lens's prerelease tag in the matching so the extension's - * compatibility is not limited by it - */ - return semver.satisfies(strippedVersion, manifest.engines.lens, { - includePrerelease: true, - }); + const appVersion = appSemVer.raw.split("-")[0]; // drop prerelease version if any, e.g. "-alpha.0" + const manifestLensEngine = manifest.engines.lens; + const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number + + if (!validVersion) { + const errorInfo = [ + `Invalid format for "manifest.engines.lens"="${manifestLensEngine}"`, + `Range versions can only be specified starting with '^'.`, + `Otherwise it's recommended to use plain %MAJOR.%MINOR to match with supported Lens version.`, + ].join("\n"); + + throw new Error(errorInfo); } - return false; + const { major: extMajor, minor: extMinor } = semver.coerce(manifestLensEngine, { + loose: true, + includePrerelease: false, + }) as semver.SemVer; + const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string; + + return semver.satisfies(appVersion, supportedVersionsByExtension, { + loose: true, + includePrerelease: false, + }); }; }; diff --git a/src/extensions/extension-installer/extension-installer.ts b/src/extensions/extension-installer/extension-installer.ts index 45d84eb0da..0c0a1554dc 100644 --- a/src/extensions/extension-installer/extension-installer.ts +++ b/src/extensions/extension-installer/extension-installer.ts @@ -74,7 +74,7 @@ export class ExtensionInstaller { }); let stderr = ""; - child.stderr.on("data", data => { + child.stderr?.on("data", data => { stderr += String(data); }); diff --git a/src/extensions/extension-loader/create-extension-instance.token.ts b/src/extensions/extension-loader/create-extension-instance.token.ts new file mode 100644 index 0000000000..d7680b018b --- /dev/null +++ b/src/extensions/extension-loader/create-extension-instance.token.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { InstalledExtension } from "../extension-discovery/extension-discovery"; +import type { LensExtension, LensExtensionConstructor } from "../lens-extension"; + +export type CreateExtensionInstance = (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => LensExtension; + +export const createExtensionInstanceInjectionToken = getInjectionToken({ + id: "create-extension-instance-token", +}); diff --git a/src/extensions/extension-loader/create-extension-instance/create-extension-instance.injectable.ts b/src/extensions/extension-loader/create-extension-instance/create-extension-instance.injectable.ts deleted file mode 100644 index d5bba359bc..0000000000 --- a/src/extensions/extension-loader/create-extension-instance/create-extension-instance.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { createExtensionInstance } from "./create-extension-instance"; -import fileSystemProvisionerStoreInjectable from "./file-system-provisioner-store/file-system-provisioner-store.injectable"; - -const createExtensionInstanceInjectable = getInjectable({ - id: "create-extension-instance", - - instantiate: (di) => createExtensionInstance({ - fileSystemProvisionerStore: di.inject(fileSystemProvisionerStoreInjectable), - }), -}); - -export default createExtensionInstanceInjectable; diff --git a/src/extensions/extension-loader/create-extension-instance/create-extension-instance.ts b/src/extensions/extension-loader/create-extension-instance/create-extension-instance.ts deleted file mode 100644 index bd5dc6c6bb..0000000000 --- a/src/extensions/extension-loader/create-extension-instance/create-extension-instance.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { LensExtensionConstructor } from "../../lens-extension"; -import type { InstalledExtension } from "../../extension-discovery/extension-discovery"; -import type { - LensExtensionDependencies } from "../../lens-extension-set-dependencies"; -import { - setLensExtensionDependencies, -} from "../../lens-extension-set-dependencies"; - -export const createExtensionInstance = - (dependencies: LensExtensionDependencies) => - (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => { - const instance = new ExtensionClass(extension); - - instance[setLensExtensionDependencies](dependencies); - - return instance; - }; diff --git a/src/extensions/extension-loader/extension-instances.injectable.ts b/src/extensions/extension-loader/extension-instances.injectable.ts new file mode 100644 index 0000000000..08e7ad4cc5 --- /dev/null +++ b/src/extensions/extension-loader/extension-instances.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { LensExtensionId, LensExtension } from "../lens-extension"; + +const extensionInstancesInjectable = getInjectable({ + id: "extension-instances", + instantiate: () => observable.map(), +}); + +export default extensionInstancesInjectable; diff --git a/src/extensions/extension-loader/extension-loader.injectable.ts b/src/extensions/extension-loader/extension-loader.injectable.ts index 87f53cd686..1686b69f96 100644 --- a/src/extensions/extension-loader/extension-loader.injectable.ts +++ b/src/extensions/extension-loader/extension-loader.injectable.ts @@ -5,20 +5,21 @@ import { getInjectable } from "@ogre-tools/injectable"; import { ExtensionLoader } from "./extension-loader"; import updateExtensionsStateInjectable from "./update-extensions-state/update-extensions-state.injectable"; -import createExtensionInstanceInjectable from "./create-extension-instance/create-extension-instance.injectable"; import { extensionRegistratorInjectionToken } from "./extension-registrator-injection-token"; import extensionInstallationCounterInjectable from "./extension-installation-counter.injectable"; +import { createExtensionInstanceInjectionToken } from "./create-extension-instance.token"; +import extensionInstancesInjectable from "./extension-instances.injectable"; const extensionLoaderInjectable = getInjectable({ id: "extension-loader", - instantiate: (di) => - new ExtensionLoader({ - updateExtensionsState: di.inject(updateExtensionsStateInjectable), - createExtensionInstance: di.inject(createExtensionInstanceInjectable), - extensionRegistrators: di.injectMany(extensionRegistratorInjectionToken), - extensionInstallationCounter: di.inject(extensionInstallationCounterInjectable), - }), + instantiate: (di) => new ExtensionLoader({ + updateExtensionsState: di.inject(updateExtensionsStateInjectable), + createExtensionInstance: di.inject(createExtensionInstanceInjectionToken), + extensionRegistrators: di.injectMany(extensionRegistratorInjectionToken), + extensionInstallationCounter: di.inject(extensionInstallationCounterInjectable), + extensionInstances: di.inject(extensionInstancesInjectable), + }), }); export default extensionLoaderInjectable; diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index e1e1896fc1..3fc89b8547 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -4,15 +4,15 @@ */ import { ipcRenderer } from "electron"; -import { EventEmitter } from "events"; import { isEqual } from "lodash"; +import type { ObservableMap } from "mobx"; import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import path from "path"; import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; import type { Disposer } from "../../common/utils"; -import { toJS } from "../../common/utils"; +import { isDefined, toJS } from "../../common/utils"; import logger from "../../main/logger"; -import type { KubernetesCluster } from "../common-api/catalog"; +import type { CatalogEntity, KubernetesCluster } from "../common-api/catalog"; import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensRendererExtension } from "../lens-renderer-extension"; @@ -20,14 +20,18 @@ import * as registries from "../registries"; import type { LensExtensionState } from "../extensions-store/extensions-store"; import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling"; import { requestExtensionLoaderInitialState } from "../../renderer/ipc"; +import assert from "assert"; +import { EventEmitter } from "../../common/event-emitter"; +import type { CreateExtensionInstance } from "./create-extension-instance.token"; const logModule = "[EXTENSIONS-LOADER]"; interface Dependencies { updateExtensionsState: (extensionsState: Record) => void; - createExtensionInstance: (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => LensExtension; - extensionRegistrators: ((extension: LensExtension, extensionInstallationCount: number) => void)[]; - extensionInstallationCounter: Map; + createExtensionInstance: CreateExtensionInstance; + readonly extensionRegistrators: ((extension: LensExtension, extensionInstallationCount: number) => void)[]; + readonly extensionInstallationCounter: Map; + readonly extensionInstances: ObservableMap; } export interface ExtensionLoading { @@ -39,23 +43,21 @@ export interface ExtensionLoading { * Loads installed extensions to the Lens application */ export class ExtensionLoader { - protected extensions = observable.map(); - protected instances = observable.map(); + protected readonly extensions = observable.map(); /** * This is the set of extensions that don't come with either * - Main.LensExtension when running in the main process * - Renderer.LensExtension when running in the renderer process */ - protected nonInstancesByName = observable.set(); + protected readonly nonInstancesByName = observable.set(); /** * This is updated by the `observe` in the constructor. DO NOT write directly to it */ - protected instancesByName = observable.map(); + protected readonly instancesByName = observable.map(); - // emits event "remove" of type LensExtension when the extension is removed - private events = new EventEmitter(); + private readonly onRemoveExtensionId = new EventEmitter<[string]>(); @observable isLoaded = false; @@ -63,10 +65,10 @@ export class ExtensionLoader { return when(() => this.isLoaded); } - constructor(protected dependencies : Dependencies) { + constructor(protected readonly dependencies: Dependencies) { makeObservable(this); - observe(this.instances, change => { + observe(this.dependencies.extensionInstances, change => { switch (change.type) { case "add": if (this.instancesByName.has(change.newValue.name)) { @@ -84,10 +86,6 @@ export class ExtensionLoader { }); } - @computed get enabledExtensionInstances() : LensExtension[] { - return [...this.instances.values()].filter(extension => extension.isEnabled); - } - @computed get userExtensions(): Map { const extensions = this.toJSON(); @@ -152,7 +150,7 @@ export class ExtensionLoader { ); } - initExtensions(extensions?: Map) { + initExtensions(extensions: Map) { this.extensions.replace(extensions); } @@ -163,7 +161,7 @@ export class ExtensionLoader { @action removeInstance(lensExtensionId: LensExtensionId) { logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); - const instance = this.instances.get(lensExtensionId); + const instance = this.dependencies.extensionInstances.get(lensExtensionId); if (!instance) { return; @@ -171,8 +169,8 @@ export class ExtensionLoader { try { instance.disable(); - this.events.emit("remove", instance); - this.instances.delete(lensExtensionId); + this.onRemoveExtensionId.emit(instance.id); + this.dependencies.extensionInstances.delete(lensExtensionId); this.nonInstancesByName.delete(instance.name); } catch (error) { logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); @@ -188,7 +186,11 @@ export class ExtensionLoader { } setIsEnabled(lensExtensionId: LensExtensionId, isEnabled: boolean) { - this.extensions.get(lensExtensionId).isEnabled = isEnabled; + const extension = this.extensions.get(lensExtensionId); + + assert(extension, `Must register extension ${lensExtensionId} with before enabling it`); + + extension.isEnabled = isEnabled; } protected async initMain() { @@ -248,14 +250,15 @@ export class ExtensionLoader { loadOnClusterManagerRenderer = () => { logger.debug(`${logModule}: load on main renderer (cluster manager)`); - return this.autoInitExtensions(async (extension: LensRendererExtension) => { + return this.autoInitExtensions(async (ext) => { + const extension = ext as LensRendererExtension; const removeItems = [ registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), ]; - this.events.on("remove", (removedExtension: LensRendererExtension) => { - if (removedExtension.id === extension.id) { + this.onRemoveExtensionId.addListener((removedExtensionId) => { + if (removedExtensionId === extension.id) { removeItems.forEach(remove => { remove(); }); @@ -266,12 +269,15 @@ export class ExtensionLoader { }); }; - loadOnClusterRenderer = (getCluster: () => KubernetesCluster) => { + loadOnClusterRenderer = (getCluster: () => CatalogEntity) => { logger.debug(`${logModule}: load on cluster renderer (dashboard)`); - this.autoInitExtensions(async (extension: LensRendererExtension) => { + this.autoInitExtensions(async (ext) => { + const entity = getCluster() as KubernetesCluster; + const extension = ext as LensRendererExtension; + // getCluster must be a callback, as the entity might be available only after an extension has been loaded - if ((await extension.isEnabledForCluster(getCluster())) === false) { + if ((await extension.isEnabledForCluster(entity)) === false) { return []; } @@ -279,8 +285,8 @@ export class ExtensionLoader { registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems), ]; - this.events.on("remove", (removedExtension: LensRendererExtension) => { - if (removedExtension.id === extension.id) { + this.onRemoveExtensionId.addListener((removedExtensionId) => { + if (removedExtensionId === extension.id) { removeItems.forEach(remove => { remove(); }); @@ -300,7 +306,7 @@ export class ExtensionLoader { const extensions = [...installedExtensions.entries()] .map(([extId, extension]) => { - const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); + const alreadyInit = this.dependencies.extensionInstances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); if (extension.isCompatible && extension.isEnabled && !alreadyInit) { try { @@ -317,7 +323,7 @@ export class ExtensionLoader { extension, ); - this.instances.set(extId, instance); + this.dependencies.extensionInstances.set(extId, instance); return { instance, @@ -332,7 +338,9 @@ export class ExtensionLoader { } return null; - }).filter(extension => Boolean(extension)); + }) + // Remove null values + .filter(isDefined); // We first need to wait until each extension's `onActivate` is resolved or rejected, // as this might register new catalog categories. Afterwards we can safely .enable the extension. @@ -346,7 +354,7 @@ export class ExtensionLoader { ); extensions.forEach(({ instance }) => { - const installationCount = (this.dependencies.extensionInstallationCounter.get(instance.sanitizedExtensionId) | 0) + 1; + const installationCount = (this.dependencies.extensionInstallationCounter.get(instance.sanitizedExtensionId) ?? 0) + 1; this.dependencies.extensionInstallationCounter.set(instance.sanitizedExtensionId, installationCount); @@ -389,7 +397,7 @@ export class ExtensionLoader { try { return __non_webpack_require__(extAbsolutePath).default; } catch (error) { - if (ipcRenderer) { + if (window && error instanceof window.Error) { console.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${error.stack || error}`, extension); } else { logger.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${error}`, { extension }); @@ -399,12 +407,12 @@ export class ExtensionLoader { return null; } - getExtension(extId: LensExtensionId): InstalledExtension { + getExtension(extId: LensExtensionId) { return this.extensions.get(extId); } - getInstanceById(extId: LensExtensionId): E { - return this.instances.get(extId) as E; + getInstanceById(extId: LensExtensionId) { + return this.dependencies.extensionInstances.get(extId); } toJSON(): Map { diff --git a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts b/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts similarity index 71% rename from src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts rename to src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts index 854953e336..896c74da6d 100644 --- a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts +++ b/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import directoryForUserDataInjectable from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import getAbsolutePathInjectable from "../../../../../common/path/get-absolute-path.injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import getAbsolutePathInjectable from "../../../common/path/get-absolute-path.injectable"; const directoryForExtensionDataInjectable = getInjectable({ id: "directory-for-extension-data", diff --git a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts b/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable.ts similarity index 67% rename from src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts rename to src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable.ts index 5c80330c0c..a0951baa8f 100644 --- a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts +++ b/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable.ts @@ -4,17 +4,18 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { FileSystemProvisionerStore } from "./file-system-provisioner-store"; -import directoryForExtensionDataInjectable from "./directory-for-extension-data/directory-for-extension-data.injectable"; +import directoryForExtensionDataInjectable from "./directory-for-extension-data.injectable"; const fileSystemProvisionerStoreInjectable = getInjectable({ id: "file-system-provisioner-store", - instantiate: (di) => - FileSystemProvisionerStore.createInstance({ - directoryForExtensionData: di.inject( - directoryForExtensionDataInjectable, - ), - }), + instantiate: (di) => { + FileSystemProvisionerStore.resetInstance(); + + return FileSystemProvisionerStore.createInstance({ + directoryForExtensionData: di.inject(directoryForExtensionDataInjectable), + }); + }, causesSideEffects: true, }); diff --git a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts b/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.ts similarity index 79% rename from src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts rename to src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.ts index 666a535c3e..fab21df3cc 100644 --- a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts +++ b/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.ts @@ -8,9 +8,9 @@ import { SHA256 } from "crypto-js"; import fse from "fs-extra"; import { action, makeObservable, observable } from "mobx"; import path from "path"; -import { BaseStore } from "../../../../common/base-store"; -import type { LensExtensionId } from "../../../lens-extension"; -import { toJS } from "../../../../common/utils"; +import { BaseStore } from "../../../common/base-store"; +import type { LensExtensionId } from "../../lens-extension"; +import { getOrInsertWith, toJS } from "../../../common/utils"; interface FSProvisionModel { extensions: Record; // extension names to paths @@ -41,16 +41,12 @@ export class FileSystemProvisionerStore extends BaseStore { * @returns path to the folder that the extension can safely write files to. */ async requestDirectory(extensionName: string): Promise { - if (!this.registeredExtensions.has(extensionName)) { + const dirPath = getOrInsertWith(this.registeredExtensions, extensionName, () => { const salt = randomBytes(32).toString("hex"); const hashedName = SHA256(`${extensionName}/${salt}`).toString(); - const dirPath = path.resolve(this.dependencies.directoryForExtensionData, hashedName); - - this.registeredExtensions.set(extensionName, dirPath); - } - - const dirPath = this.registeredExtensions.get(extensionName); + return path.resolve(this.dependencies.directoryForExtensionData, hashedName); + }); await fse.ensureDir(dirPath); diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 561b314e6a..62799f9843 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -6,10 +6,11 @@ import { BaseStore } from "../common/base-store"; import * as path from "path"; import type { LensExtension } from "./lens-extension"; +import assert from "assert"; export abstract class ExtensionStore extends BaseStore { readonly displayName = "ExtensionStore"; - protected extension: LensExtension; + protected extension?: LensExtension; loadExtension(extension: LensExtension) { this.extension = extension; @@ -24,6 +25,8 @@ export abstract class ExtensionStore extends BaseStore { } protected cwd() { + assert(this.extension, "must call this.load() first"); + return path.join(super.cwd(), "extension-store", this.extension.name); } } diff --git a/src/extensions/extensions-store/extensions-store.ts b/src/extensions/extensions-store/extensions-store.ts index fe43601abf..c1345bfec9 100644 --- a/src/extensions/extensions-store/extensions-store.ts +++ b/src/extensions/extensions-store/extensions-store.ts @@ -42,7 +42,7 @@ export class ExtensionsStore extends BaseStore { return isBundled || Boolean(this.state.get(id)?.enabled); } - mergeState = action((extensionsState: Record) => { + mergeState = action((extensionsState: Record | [LensExtensionId, LensExtensionState][]) => { this.state.merge(extensionsState); }); diff --git a/src/extensions/extensions.injectable.ts b/src/extensions/extensions.injectable.ts index 36d6af1a92..7cc019a318 100644 --- a/src/extensions/extensions.injectable.ts +++ b/src/extensions/extensions.injectable.ts @@ -4,15 +4,14 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import extensionLoaderInjectable from "./extension-loader/extension-loader.injectable"; +import extensionInstancesInjectable from "./extension-loader/extension-instances.injectable"; const extensionsInjectable = getInjectable({ id: "extensions", - instantiate: (di) => { - const extensionLoader = di.inject(extensionLoaderInjectable); + const extensionInstances = di.inject(extensionInstancesInjectable); - return computed(() => extensionLoader.enabledExtensionInstances); + return computed(() => [...extensionInstances.values()].filter(extension => extension.isEnabled)); }, }); diff --git a/src/extensions/lens-extension-set-dependencies.ts b/src/extensions/lens-extension-set-dependencies.ts index d20f5cb1fc..42848f7859 100644 --- a/src/extensions/lens-extension-set-dependencies.ts +++ b/src/extensions/lens-extension-set-dependencies.ts @@ -3,12 +3,29 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { FileSystemProvisionerStore } from "./extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store"; - -// This symbol encapsulates setting of dependencies to only happen locally in Lens Core -// and not by e.g. authors of extensions -export const setLensExtensionDependencies = Symbol("set-lens-extension-dependencies"); +import type { IComputedValue } from "mobx"; +import type { CatalogCategoryRegistry } from "../common/catalog"; +import type { NavigateToRoute } from "../common/front-end-routing/navigate-to-route-injection-token"; +import type { Route } from "../common/front-end-routing/route-injection-token"; +import type { CatalogEntityRegistry as MainCatalogEntityRegistry } from "../main/catalog"; +import type { CatalogEntityRegistry as RendererCatalogEntityRegistry } from "../renderer/api/catalog/entity/registry"; +import type { GetExtensionPageParameters } from "../renderer/routes/get-extension-page-parameters.injectable"; +import type { FileSystemProvisionerStore } from "./extension-loader/file-system-provisioner-store/file-system-provisioner-store"; +import type { NavigateForExtension } from "../main/start-main-application/lens-window/navigate-for-extension.injectable"; export interface LensExtensionDependencies { - fileSystemProvisionerStore: FileSystemProvisionerStore; + readonly fileSystemProvisionerStore: FileSystemProvisionerStore; +} + +export interface LensMainExtensionDependencies extends LensExtensionDependencies { + readonly entityRegistry: MainCatalogEntityRegistry; + readonly navigate: NavigateForExtension; +} + +export interface LensRendererExtensionDependencies extends LensExtensionDependencies { + navigateToRoute: NavigateToRoute; + getExtensionPageParameters: GetExtensionPageParameters; + readonly routes: IComputedValue[]>; + readonly entityRegistry: RendererCatalogEntityRegistry; + readonly categoryRegistry: CatalogCategoryRegistry; } diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 36a0955f47..ab1f1b0add 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -4,17 +4,13 @@ */ import type { InstalledExtension } from "./extension-discovery/extension-discovery"; -import { action, observable, makeObservable, computed } from "mobx"; +import { action, computed, makeObservable, observable } from "mobx"; import logger from "../main/logger"; import type { ProtocolHandlerRegistration } from "./registries"; import type { PackageJson } from "type-fest"; import type { Disposer } from "../common/utils"; import { disposer } from "../common/utils"; -import type { - LensExtensionDependencies } from "./lens-extension-set-dependencies"; -import { - setLensExtensionDependencies, -} from "./lens-extension-set-dependencies"; +import type { LensExtensionDependencies } from "./lens-extension-set-dependencies"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -24,11 +20,21 @@ export interface LensExtensionManifest extends PackageJson { version: string; main?: string; // path to %ext/dist/main.js renderer?: string; // path to %ext/dist/renderer.js + /** + * Supported Lens version engine by extension could be defined in `manifest.engines.lens` + * Only MAJOR.MINOR version is taken in consideration. + */ + engines: { + lens: string; // "semver"-package format + npm?: string; + node?: string; + }; } -export const Disposers = Symbol(); +export const lensExtensionDependencies = Symbol("lens-extension-dependencies"); +export const Disposers = Symbol("disposers"); -export class LensExtension { +export class LensExtension { readonly id: LensExtensionId; readonly manifest: LensExtensionManifest; readonly manifestPath: string; @@ -68,11 +74,7 @@ export class LensExtension { return this.manifest.description; } - private dependencies: LensExtensionDependencies; - - [setLensExtensionDependencies] = (dependencies: LensExtensionDependencies) => { - this.dependencies = dependencies; - }; + readonly [lensExtensionDependencies]!: Dependencies; /** * getExtensionFileFolder returns the path to an already created folder. This @@ -82,11 +84,11 @@ export class LensExtension { * folder name. */ async getExtensionFileFolder(): Promise { - return this.dependencies.fileSystemProvisionerStore.requestDirectory(this.id); + return this[lensExtensionDependencies].fileSystemProvisionerStore.requestDirectory(this.id); } @action - async enable(register: (ext: LensExtension) => Promise) { + async enable(register: (ext: this) => Promise) { if (this._isEnabled) { return; } @@ -136,11 +138,13 @@ export function sanitizeExtensionName(name: string) { return name.replace("@", "").replace("/", "--"); } -export const getSanitizedPath = (...parts: string[]) => parts - .filter(Boolean) - .join("/") - .replace(/\/+/g, "/") - .replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id) +export function getSanitizedPath(...parts: string[]) { + return parts + .filter(Boolean) + .join("/") + .replace(/\/+/g, "/") + .replace(/\/$/, ""); +} // normalize multi-slashes (e.g. coming from page.id) export function extensionDisplayName(name: string, version: string) { return `${name}@${version}`; diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index bda6024522..accc87989c 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -3,43 +3,42 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { LensExtension } from "./lens-extension"; -import { WindowManager } from "../main/window-manager"; -import { catalogEntityRegistry } from "../main/catalog"; +import { LensExtension, lensExtensionDependencies } from "./lens-extension"; import type { CatalogEntity } from "../common/catalog"; import type { IObservableArray } from "mobx"; import type { MenuRegistration } from "../main/menu/menu-registration"; import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration"; import type { ShellEnvModifier } from "../main/shell-session/shell-env-modifier/shell-env-modifier-registration"; +import type { LensMainExtensionDependencies } from "./lens-extension-set-dependencies"; -export class LensMainExtension extends LensExtension { +export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; trayMenus: TrayMenuRegistration[] = []; /** * implement this to modify the shell environment that Lens terminals are opened with. The ShellEnvModifier type has the signature - * + * * (ctx: ShellEnvContext, env: Record) => Record - * + * * @param ctx the shell environment context, specifically the relevant catalog entity for the terminal. This can be used, for example, to get * cluster-specific information that can be made available in the shell environment by the implementation of terminalShellEnvModifier - * + * * @param env the current shell environment that the terminal will be opened with. The implementation should modify this as desired. - * + * * @returns the modified shell environment that the terminal will be opened with. The implementation must return env as passed in, if it * does not modify the shell environment */ terminalShellEnvModifier?: ShellEnvModifier; async navigate(pageId?: string, params?: Record, frameId?: number) { - return WindowManager.getInstance().navigateExtension(this.id, pageId, params, frameId); + await this[lensExtensionDependencies].navigate(this.id, pageId, params, frameId); } addCatalogSource(id: string, source: IObservableArray) { - catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source); + this[lensExtensionDependencies].entityRegistry.addObservableSource(`${this.name}:${id}`, source); } removeCatalogSource(id: string) { - catalogEntityRegistry.removeSource(`${this.name}:${id}`); + this[lensExtensionDependencies].entityRegistry.removeSource(`${this.name}:${id}`); } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 5461e0b568..25b1e15061 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -4,13 +4,11 @@ */ import type * as registries from "./registries"; -import { Disposers, LensExtension } from "./lens-extension"; +import { Disposers, LensExtension, lensExtensionDependencies } from "./lens-extension"; import type { CatalogEntity } from "../common/catalog"; import type { Disposer } from "../common/utils"; -import type { EntityFilter } from "../renderer/api/catalog-entity-registry"; -import { catalogEntityRegistry } from "../renderer/api/catalog-entity-registry"; +import type { EntityFilter } from "../renderer/api/catalog/entity/registry"; import type { CategoryFilter } from "../renderer/api/catalog-category-registry"; -import { catalogCategoryRegistry } from "../renderer/api/catalog-category-registry"; import type { TopBarRegistration } from "../renderer/components/layout/top-bar/top-bar-registration"; import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; @@ -23,17 +21,14 @@ import type { StatusBarRegistration } from "../renderer/components/status-bar/st import type { KubeObjectMenuRegistration } from "../renderer/components/kube-object-menu/kube-object-menu-registration"; import type { WorkloadsOverviewDetailRegistration } from "../renderer/components/+workloads-overview/workloads-overview-detail-registration"; import type { KubeObjectStatusRegistration } from "../renderer/components/kube-object-status-icon/kube-object-status-registration"; -import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "./as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import routesInjectable from "../renderer/routes/routes.injectable"; import { fromPairs, map, matches, toPairs } from "lodash/fp"; -import extensionPageParametersInjectable from "../renderer/routes/extension-page-parameters.injectable"; import { pipeline } from "@ogre-tools/fp"; -import { getExtensionRoutePath } from "../renderer/routes/get-extension-route-path"; -import { navigateToRouteInjectionToken } from "../common/front-end-routing/navigate-to-route-injection-token"; -import type { AppPreferenceTabRegistration } from "../renderer/components/+preferences/app-preference-tab/app-preference-tab-registration"; +import { getExtensionRoutePath } from "../renderer/routes/for-extension"; +import type { LensRendererExtensionDependencies } from "./lens-extension-set-dependencies"; import type { KubeObjectHandlerRegistration } from "../renderer/kube-object/handler"; +import type { AppPreferenceTabRegistration } from "../renderer/components/+preferences/app-preference-tab/app-preference-tab-registration"; -export class LensRendererExtension extends LensExtension { +export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; clusterPages: registries.PageRegistration[] = []; clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; @@ -54,49 +49,39 @@ export class LensRendererExtension extends LensExtension { customCategoryViews: CustomCategoryViewRegistration[] = []; kubeObjectHandlers: KubeObjectHandlerRegistration[] = []; - async navigate

(pageId?: string, params?: P) { - const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi( - Environments.renderer, - ); + async navigate(pageId?: string, params: object = {}) { + const routes = this[lensExtensionDependencies].routes.get(); + const targetRegistration = [...this.globalPages, ...this.clusterPages] + .find(registration => registration.id === (pageId || undefined)); - const navigateToRoute = di.inject(navigateToRouteInjectionToken); - const routes = di.inject(routesInjectable).get(); - - const targetRegistration = [...this.globalPages, ...this.clusterPages].find( - registration => registration.id === (pageId || undefined), - ); + if (!targetRegistration) { + return; + } const targetRoutePath = getExtensionRoutePath(this, targetRegistration.id); - const targetRoute = routes.find(matches({ path: targetRoutePath })); - if (targetRoute) { - const normalizedParams = di.inject(extensionPageParametersInjectable, { - extension: this, - registration: targetRegistration, - }); - - const query = pipeline( - params, - - toPairs, - - map(([key, value]) => { - const normalizedParam = normalizedParams[key]; - - return [ - key, - normalizedParam.stringify(value), - ]; - }), - - fromPairs, - ); - - navigateToRoute(targetRoute, { - query, - }); + if (!targetRoute) { + return; } + + const normalizedParams = this[lensExtensionDependencies].getExtensionPageParameters({ + extension: this, + registration: targetRegistration, + }); + const query = pipeline( + params, + toPairs, + map(([key, value]) => [ + key, + normalizedParams[key].stringify(value), + ]), + fromPairs, + ); + + this[lensExtensionDependencies].navigateToRoute(targetRoute, { + query, + }); } /** @@ -115,7 +100,7 @@ export class LensRendererExtension extends LensExtension { * @returns A function to clean up the filter */ addCatalogFilter(fn: EntityFilter): Disposer { - const dispose = catalogEntityRegistry.addCatalogFilter(fn); + const dispose = this[lensExtensionDependencies].entityRegistry.addCatalogFilter(fn); this[Disposers].push(dispose); @@ -128,7 +113,7 @@ export class LensRendererExtension extends LensExtension { * @returns A function to clean up the filter */ addCatalogCategoryFilter(fn: CategoryFilter): Disposer { - const dispose = catalogCategoryRegistry.addCatalogCategoryFilter(fn); + const dispose = this[lensExtensionDependencies].categoryRegistry.addCatalogCategoryFilter(fn); this[Disposers].push(dispose); diff --git a/src/extensions/main-api/catalog.ts b/src/extensions/main-api/catalog.ts index 1881f9fe85..07adbe9d4b 100644 --- a/src/extensions/main-api/catalog.ts +++ b/src/extensions/main-api/catalog.ts @@ -4,14 +4,23 @@ */ import type { CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry as registry } from "../../main/catalog"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable"; +import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; -export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; +export const catalogCategories = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable); +const catalogEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable); -export class CatalogEntityRegistry { - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); - } +export interface CatalogEntityRegistry { + getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[]; + /** + * @deprecated use a cast instead of a unbounded type parameter + */ + getItemsForApiKind(apiVersion: string, kind: string): T[]; } -export const catalogEntities = new CatalogEntityRegistry(); +export const catalogEntities: CatalogEntityRegistry = { + getItemsForApiKind(apiVersion: string, kind: string) { + return catalogEntityRegistry.filterItemsForApiKind(apiVersion, kind); + }, +}; diff --git a/src/extensions/main-api/k8s-api.ts b/src/extensions/main-api/k8s-api.ts index e9ea0650ac..05cffffdbf 100644 --- a/src/extensions/main-api/k8s-api.ts +++ b/src/extensions/main-api/k8s-api.ts @@ -11,44 +11,4 @@ export function isAllowedResource(...args: any[]) { return Boolean(void args); } -export { ResourceStack } from "../../common/k8s/resource-stack"; -export { apiManager } from "../../common/k8s-api/api-manager"; -export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; -export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; -export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; -export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints/pods.api"; -export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints/nodes.api"; -export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints/deployment.api"; -export { DaemonSet, daemonSetApi } from "../../common/k8s-api/endpoints/daemon-set.api"; -export { StatefulSet, statefulSetApi } from "../../common/k8s-api/endpoints/stateful-set.api"; -export { Job, jobApi } from "../../common/k8s-api/endpoints/job.api"; -export { CronJob, cronJobApi } from "../../common/k8s-api/endpoints/cron-job.api"; -export { ConfigMap, configMapApi } from "../../common/k8s-api/endpoints/configmap.api"; -export { Secret, secretsApi } from "../../common/k8s-api/endpoints/secret.api"; -export { ReplicaSet, replicaSetApi } from "../../common/k8s-api/endpoints/replica-set.api"; -export { ResourceQuota, resourceQuotaApi } from "../../common/k8s-api/endpoints/resource-quota.api"; -export { LimitRange, limitRangeApi } from "../../common/k8s-api/endpoints/limit-range.api"; -export { HorizontalPodAutoscaler, hpaApi } from "../../common/k8s-api/endpoints/hpa.api"; -export { PodDisruptionBudget, pdbApi } from "../../common/k8s-api/endpoints/poddisruptionbudget.api"; -export { Service, serviceApi } from "../../common/k8s-api/endpoints/service.api"; -export { Endpoint, endpointApi } from "../../common/k8s-api/endpoints/endpoint.api"; -export { Ingress, ingressApi, IngressApi } from "../../common/k8s-api/endpoints/ingress.api"; -export { NetworkPolicy, networkPolicyApi } from "../../common/k8s-api/endpoints/network-policy.api"; -export { PersistentVolume, persistentVolumeApi } from "../../common/k8s-api/endpoints/persistent-volume.api"; -export { PersistentVolumeClaim, pvcApi, PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints/persistent-volume-claims.api"; -export { StorageClass, storageClassApi } from "../../common/k8s-api/endpoints/storage-class.api"; -export { Namespace, namespacesApi } from "../../common/k8s-api/endpoints/namespaces.api"; -export { KubeEvent, eventApi } from "../../common/k8s-api/endpoints/events.api"; -export { ServiceAccount, serviceAccountsApi } from "../../common/k8s-api/endpoints/service-accounts.api"; -export { Role, roleApi } from "../../common/k8s-api/endpoints/role.api"; -export { RoleBinding, roleBindingApi } from "../../common/k8s-api/endpoints/role-binding.api"; -export { ClusterRole, clusterRoleApi } from "../../common/k8s-api/endpoints/cluster-role.api"; -export { ClusterRoleBinding, clusterRoleBindingApi } from "../../common/k8s-api/endpoints/cluster-role-binding.api"; -export { CustomResourceDefinition, crdApi } from "../../common/k8s-api/endpoints/crd.api"; - -// types -export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; -export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints/pods.api"; -export type { ISecretRef } from "../../common/k8s-api/endpoints/secret.api"; -export type { KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; -export type { KubeObjectStoreLoadAllParams, KubeObjectStoreLoadingParams, KubeObjectStoreSubscribeParams } from "../../common/k8s-api/kube-object.store"; +export * from "../common-api/k8s-api"; diff --git a/src/extensions/main-api/navigation.ts b/src/extensions/main-api/navigation.ts index 178f3d6d16..375d2bac74 100644 --- a/src/extensions/main-api/navigation.ts +++ b/src/extensions/main-api/navigation.ts @@ -3,8 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { WindowManager } from "../../main/window-manager"; +import { + Environments, + getEnvironmentSpecificLegacyGlobalDiForExtensionApi, +} from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; + +import navigateInjectable from "../../main/start-main-application/lens-window/navigate.injectable"; export function navigate(url: string) { - return WindowManager.getInstance().navigate(url); + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.main); + + const navigate = di.inject(navigateInjectable); + + return navigate(url); } diff --git a/src/extensions/npm/extensions/.gitignore b/src/extensions/npm/extensions/.gitignore index 81174aebbf..27459f522b 100644 --- a/src/extensions/npm/extensions/.gitignore +++ b/src/extensions/npm/extensions/.gitignore @@ -1,3 +1,2 @@ dist/ -yarn.lock __mocks__/ diff --git a/src/extensions/npm/extensions/package-lock.json b/src/extensions/npm/extensions/package-lock.json new file mode 100644 index 0000000000..5cca29ab21 --- /dev/null +++ b/src/extensions/npm/extensions/package-lock.json @@ -0,0 +1,594 @@ +{ + "name": "@k8slens/extensions", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/runtime": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", + "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "@material-ui/core": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz", + "integrity": "sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/styles": "^4.11.4", + "@material-ui/system": "^4.12.1", + "@material-ui/types": "5.1.0", + "@material-ui/utils": "^4.11.2", + "@types/react-transition-group": "^4.2.0", + "clsx": "^1.0.4", + "hoist-non-react-statics": "^3.3.2", + "popper.js": "1.16.1-lts", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0", + "react-transition-group": "^4.4.0" + } + }, + "@material-ui/styles": { + "version": "4.11.5", + "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", + "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", + "requires": { + "@babel/runtime": "^7.4.4", + "@emotion/hash": "^0.8.0", + "@material-ui/types": "5.1.0", + "@material-ui/utils": "^4.11.3", + "clsx": "^1.0.4", + "csstype": "^2.5.2", + "hoist-non-react-statics": "^3.3.2", + "jss": "^10.5.1", + "jss-plugin-camel-case": "^10.5.1", + "jss-plugin-default-unit": "^10.5.1", + "jss-plugin-global": "^10.5.1", + "jss-plugin-nested": "^10.5.1", + "jss-plugin-props-sort": "^10.5.1", + "jss-plugin-rule-value-function": "^10.5.1", + "jss-plugin-vendor-prefixer": "^10.5.1", + "prop-types": "^15.7.2" + } + }, + "@material-ui/system": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", + "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.3", + "csstype": "^2.5.2", + "prop-types": "^15.7.2" + } + }, + "@material-ui/types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==" + }, + "@material-ui/utils": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", + "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", + "requires": { + "@babel/runtime": "^7.4.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + }, + "@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" + }, + "@types/node": { + "version": "14.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.14.tgz", + "integrity": "sha512-rsAj2u8Xkqfc332iXV12SqIsjVi07H479bOP4q94NAcjzmAvapumEhuVIt53koEf7JFrpjgNKjBga5Pnn/GL8A==" + }, + "@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + }, + "@types/react": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz", + "integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + } + } + }, + "@types/react-dom": { + "version": "17.0.16", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.16.tgz", + "integrity": "sha512-DWcXf8EbMrO/gWnQU7Z88Ws/p16qxGpPyjTKTpmBSFKeE+HveVubqGO1CVK7FrwlWD5MuOcvh8gtd0/XO38NdQ==", + "requires": { + "@types/react": "^17" + } + }, + "@types/react-router": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", + "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "@types/react-transition-group": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", + "integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==", + "requires": { + "@types/react": "*" + } + }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==" + }, + "clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + }, + "conf": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", + "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", + "requires": { + "ajv": "^6.12.2", + "atomically": "^1.3.1", + "debounce-fn": "^4.0.0", + "dot-prop": "^5.2.0", + "env-paths": "^2.2.0", + "json-schema-typed": "^7.0.3", + "make-dir": "^3.1.0", + "onetime": "^5.1.0", + "pkg-up": "^3.1.0", + "semver": "^7.3.2" + } + }, + "css-vendor": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", + "requires": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } + }, + "csstype": { + "version": "2.6.20", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", + "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==" + }, + "debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "requires": { + "mimic-fn": "^3.0.0" + } + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + } + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "requires": { + "is-obj": "^2.0.0" + } + }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "requires": { + "locate-path": "^3.0.0" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, + "hyphenate-style-name": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" + }, + "is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==" + }, + "jss": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.9.0.tgz", + "integrity": "sha512-YpzpreB6kUunQBbrlArlsMpXYyndt9JATbt95tajx0t4MTJJcCJdd4hdNpHmOIDiUJrF/oX5wtVFrS3uofWfGw==", + "requires": { + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" + } + } + }, + "jss-plugin-camel-case": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.9.0.tgz", + "integrity": "sha512-UH6uPpnDk413/r/2Olmw4+y54yEF2lRIV8XIZyuYpgPYTITLlPOsq6XB9qeqv+75SQSg3KLocq5jUBXW8qWWww==", + "requires": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.9.0" + } + }, + "jss-plugin-default-unit": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.9.0.tgz", + "integrity": "sha512-7Ju4Q9wJ/MZPsxfu4T84mzdn7pLHWeqoGd/D8O3eDNNJ93Xc8PxnLmV8s8ZPNRYkLdxZqKtm1nPQ0BM4JRlq2w==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.9.0" + } + }, + "jss-plugin-global": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.9.0.tgz", + "integrity": "sha512-4G8PHNJ0x6nwAFsEzcuVDiBlyMsj2y3VjmFAx/uHk/R/gzJV+yRHICjT4MKGGu1cJq2hfowFWCyrr/Gg37FbgQ==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.9.0" + } + }, + "jss-plugin-nested": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.9.0.tgz", + "integrity": "sha512-2UJnDrfCZpMYcpPYR16oZB7VAC6b/1QLsRiAutOt7wJaaqwCBvNsosLEu/fUyKNQNGdvg2PPJFDO5AX7dwxtoA==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.9.0", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-props-sort": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.9.0.tgz", + "integrity": "sha512-7A76HI8bzwqrsMOJTWKx/uD5v+U8piLnp5bvru7g/3ZEQOu1+PjHvv7bFdNO3DwNPC9oM0a//KwIJsIcDCjDzw==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.9.0" + } + }, + "jss-plugin-rule-value-function": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.9.0.tgz", + "integrity": "sha512-IHJv6YrEf8pRzkY207cPmdbBstBaE+z8pazhPShfz0tZSDtRdQua5jjg6NMz3IbTasVx9FdnmptxPqSWL5tyJg==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.9.0", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-vendor-prefixer": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.9.0.tgz", + "integrity": "sha512-MbvsaXP7iiVdYVSEoi+blrW+AYnTDvHTW6I6zqi7JcwXdc6I9Kbm234nEblayhF38EftoenbM+5218pidmC5gA==", + "requires": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.9.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==" + }, + "mobx": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.5.0.tgz", + "integrity": "sha512-pHZ/cySF00FVENDWIDzJyoObFahK6Eg4d0papqm6d7yMkxWTZ/S/csqJX1A3PsYy4t5k3z2QnlwuCfMW5lSEwA==" + }, + "mobx-react": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-7.4.0.tgz", + "integrity": "sha512-gbUwaKZK09SiAleTMxNMKs1MYKTpoIEWJLTLRIR/xnALuuHET8wkL8j1nbc1/6cDkOWVyKz/ReftILx0Pdh2PQ==", + "requires": { + "mobx-react-lite": "^3.4.0" + } + }, + "mobx-react-lite": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.0.tgz", + "integrity": "sha512-bRuZp3C0itgLKHu/VNxi66DN/XVkQG7xtoBVWxpvC5FhAqbOCP21+nPhULjnzEqd7xBMybp6KwytdUpZKEgpIQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + }, + "dependencies": { + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + } + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "requires": { + "find-up": "^3.0.0" + } + }, + "popper.js": { + "version": "1.16.1-lts", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", + "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==" + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "react-transition-group": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" + }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==" + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/src/extensions/npm/extensions/package.json b/src/extensions/npm/extensions/package.json index 09db697b35..0086825b22 100644 --- a/src/extensions/npm/extensions/package.json +++ b/src/extensions/npm/extensions/package.json @@ -2,7 +2,7 @@ "name": "@k8slens/extensions", "productName": "OpenLens extensions", "description": "OpenLens - Open Source Kubernetes IDE: extensions", - "version": "0.0.0", + "version": "0.0.1", "copyright": "© 2021 OpenLens Authors", "license": "MIT", "main": "dist/src/extensions/extension-api.js", @@ -16,10 +16,15 @@ "name": "OpenLens Authors" }, "dependencies": { - "@types/node": "14.17.14", - "@types/react-select": "3.1.2", "@material-ui/core": "4.12.3", + "@types/node": "14.17.14", + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.16", + "@types/react-router": "^5.1.18", + "@types/react-router-dom": "^5.3.3", "conf": "^7.0.1", + "mobx": "^6.5.0", + "mobx-react": "^7.3.0", "typed-emitter": "^1.3.1" } } diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index 73dd6141b8..b0c833f295 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -5,6 +5,7 @@ // Base class for extensions-api registries import { action, observable, makeObservable } from "mobx"; +import type { Disposer } from "../../common/utils"; import { Singleton } from "../../common/utils"; import type { LensExtension } from "../lens-extension"; @@ -21,7 +22,7 @@ export class BaseRegistry extends Singleton { } @action - add(items: T | T[], extension?: LensExtension) { + add(items: T | T[], extension?: LensExtension): Disposer { const itemArray = [items].flat() as T[]; itemArray.forEach(item => { @@ -33,7 +34,7 @@ export class BaseRegistry extends Singleton { // eslint-disable-next-line unused-imports/no-unused-vars-ts protected getRegisteredItem(item: T, extension?: LensExtension): I { - return item as any; + return item as never; } @action diff --git a/src/extensions/registries/catalog-entity-detail-registry.ts b/src/extensions/registries/catalog-entity-detail-registry.ts index ac1f676ebf..9c2aeb348c 100644 --- a/src/extensions/registries/catalog-entity-detail-registry.ts +++ b/src/extensions/registries/catalog-entity-detail-registry.ts @@ -4,6 +4,7 @@ */ import type React from "react"; +import type { Disposer } from "../../common/utils"; import type { CatalogEntity } from "../common-api/catalog"; import { BaseRegistry } from "./base-registry"; @@ -23,6 +24,10 @@ export interface CatalogEntityDetailRegistration { } export class CatalogEntityDetailRegistry extends BaseRegistry> { + add(items: CatalogEntityDetailRegistration | CatalogEntityDetailRegistration[]): Disposer { + return super.add(items as never); + } + getItemsForKind(kind: string, apiVersion: string) { const items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion); diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts index 964d575b52..7f6620fc95 100644 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ b/src/extensions/registries/kube-object-detail-registry.ts @@ -4,6 +4,7 @@ */ import type React from "react"; +import type { Disposer } from "../../common/utils"; import type { KubeObjectDetailsProps } from "../renderer-api/components"; import type { KubeObject } from "../renderer-api/k8s-api"; import { BaseRegistry } from "./base-registry"; @@ -12,14 +13,20 @@ export interface KubeObjectDetailComponents { Details: React.ComponentType>; } -export interface KubeObjectDetailRegistration { +export interface KubeObjectDetailRegistration { kind: string; apiVersions: string[]; - components: KubeObjectDetailComponents; + components: KubeObjectDetailComponents; priority?: number; } export class KubeObjectDetailRegistry extends BaseRegistry { + add(items: KubeObjectDetailRegistration[]): Disposer; + add(item: KubeObjectDetailRegistration): Disposer; + add(items: KubeObjectDetailRegistration | KubeObjectDetailRegistration[]): Disposer { + return super.add(items); + } + getItemsForKind(kind: string, apiVersion: string) { const items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion); diff --git a/src/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index c4520b11e5..b698460a54 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -40,6 +40,6 @@ export interface RegisteredPage { id: string; extensionId: string; url: string; // registered extension's page URL (without page params) - params: PageParams; // normalized params + params: PageParams>; // normalized params components: PageComponents; // normalized components } diff --git a/src/extensions/registries/protocol-handler.ts b/src/extensions/registries/protocol-handler.ts index c463243131..9fdf390bee 100644 --- a/src/extensions/registries/protocol-handler.ts +++ b/src/extensions/registries/protocol-handler.ts @@ -20,12 +20,12 @@ export interface RouteParams { /** * the parts of the URI query string */ - search: Record; + search: Record; /** * the matching parts of the path. The dynamic parts of the URI path. */ - pathname: Record; + pathname: Record; /** * if the most specific path schema that is matched does not cover the whole diff --git a/src/extensions/renderer-api/catalog.ts b/src/extensions/renderer-api/catalog.ts index 34f6fcd0bb..87506926e2 100644 --- a/src/extensions/renderer-api/catalog.ts +++ b/src/extensions/renderer-api/catalog.ts @@ -5,21 +5,26 @@ import type { CatalogCategory, CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry"; -import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog-entity-registry"; +import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog/entity/registry"; import type { Disposer } from "../../common/utils"; -export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; +import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import catalogEntityRegistryInjectable from "../../renderer/api/catalog/entity/registry.injectable"; + +export const catalogCategories = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable); + +const internalEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable); export class CatalogEntityRegistry { /** * Currently active/visible entity */ get activeEntity() { - return registry.activeEntity; + return internalEntityRegistry.activeEntity; } get entities(): Map { - return registry.entities; + return internalEntityRegistry.entities; } getById(id: string) { @@ -27,11 +32,11 @@ export class CatalogEntityRegistry { } getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); + return internalEntityRegistry.getItemsForApiKind(apiVersion, kind); } getItemsForCategory(category: CatalogCategory): T[] { - return registry.getItemsForCategory(category); + return internalEntityRegistry.getItemsForCategory(category); } /** @@ -43,7 +48,7 @@ export class CatalogEntityRegistry { * @returns A function to remove that hook */ addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer { - return registry.addOnBeforeRun(onBeforeRun); + return internalEntityRegistry.addOnBeforeRun(onBeforeRun); } } diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 13facef264..6946a8aa66 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -12,9 +12,9 @@ import commandOverlayInjectable from "../../renderer/components/command-palette/ import createPodLogsTabInjectable from "../../renderer/components/dock/logs/create-pod-logs-tab.injectable"; import createWorkloadLogsTabInjectable from "../../renderer/components/dock/logs/create-workload-logs-tab.injectable"; import sendCommandInjectable from "../../renderer/components/dock/terminal/send-command.injectable"; -import { podsStore } from "../../renderer/components/+workloads-pods/pods.store"; import renameTabInjectable from "../../renderer/components/dock/dock/rename-tab.injectable"; import { asLegacyGlobalObjectForExtensionApiWithModifications } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications"; +import { podStore } from "../../renderer/components/+workloads-pods/legacy-store"; import { ConfirmDialog as _ConfirmDialog } from "../../renderer/components/confirm-dialog"; import type { ConfirmDialogBooleanParams, ConfirmDialogParams, ConfirmDialogProps } from "../../renderer/components/confirm-dialog"; import openConfirmDialogInjectable from "../../renderer/components/confirm-dialog/open.injectable"; @@ -37,9 +37,7 @@ export * from "../../renderer/components/switch"; export * from "../../renderer/components/input/input"; // command-overlay -export const CommandOverlay = asLegacyGlobalForExtensionApi( - commandOverlayInjectable, -); +export const CommandOverlay = asLegacyGlobalForExtensionApi(commandOverlayInjectable); export type { CategoryColumnRegistration, @@ -94,39 +92,30 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; -export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi( - createTerminalTabInjectable, +export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable); + +export const terminalStore = asLegacyGlobalObjectForExtensionApiWithModifications( + terminalStoreInjectable, + { + sendCommand: asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable), + }, ); -export const terminalStore = - asLegacyGlobalObjectForExtensionApiWithModifications( - terminalStoreInjectable, - { - sendCommand: asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable), - }, - ); +const renameTab = asLegacyGlobalFunctionForExtensionApi(renameTabInjectable); export const logTabStore = asLegacyGlobalObjectForExtensionApiWithModifications( logTabStoreInjectable, { - createPodTab: asLegacyGlobalFunctionForExtensionApi( - createPodLogsTabInjectable, - ), - - createWorkloadTab: asLegacyGlobalFunctionForExtensionApi( - createWorkloadLogsTabInjectable, - ), - + createPodTab: asLegacyGlobalFunctionForExtensionApi(createPodLogsTabInjectable), + createWorkloadTab: asLegacyGlobalFunctionForExtensionApi(createWorkloadLogsTabInjectable), renameTab: (tabId: string): void => { - const renameTab = - asLegacyGlobalFunctionForExtensionApi(renameTabInjectable); + const { selectedPodId } = logTabStore.getData(tabId) ?? {}; + const pod = selectedPodId && podStore.getById(selectedPodId); - const tabData = logTabStore.getData(tabId); - const pod = podsStore.getById(tabData.selectedPodId); - - renameTab(tabId, `Pod ${pod.getName()}`); + if (pod) { + renameTab(tabId, `Pod ${pod.getName()}`); + } }, - tabs: undefined, }, ); diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index f4952b2cd6..aa7e4a5c02 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -6,10 +6,39 @@ import type { KubeResource } from "../../common/rbac"; import isAllowedResourceInjectable from "../../common/utils/is-allowed-resource.injectable"; import { castArray } from "lodash/fp"; import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import clusterRoleBindingApiInjectable from "../../common/k8s-api/endpoints/cluster-role-binding.api.injectable"; +import clusterRoleApiInjectable from "../../common/k8s-api/endpoints/cluster-role.api.injectable"; +import serviceAccountApiInjectable from "../../common/k8s-api/endpoints/service-account.api.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import roleApiInjectable from "../../common/k8s-api/endpoints/role.api.injectable"; +import podApiInjectable from "../../common/k8s-api/endpoints/pod.api.injectable"; +import daemonSetApiInjectable from "../../common/k8s-api/endpoints/daemon-set.api.injectable"; +import replicaSetApiInjectable from "../../common/k8s-api/endpoints/replica-set.api.injectable"; +import statefulSetApiInjectable from "../../common/k8s-api/endpoints/stateful-set.api.injectable"; +import deploymentApiInjectable from "../../common/k8s-api/endpoints/deployment.api.injectable"; +import jobApiInjectable from "../../common/k8s-api/endpoints/job.api.injectable"; +import cronJobApiInjectable from "../../common/k8s-api/endpoints/cron-job.api.injectable"; +import nodeApiInjectable from "../../common/k8s-api/endpoints/node.api.injectable"; +import configMapApiInjectable from "../../common/k8s-api/endpoints/config-map.api.injectable"; +import secretApiInjectable from "../../common/k8s-api/endpoints/secret.api.injectable"; +import resourceQuotaApiInjectable from "../../common/k8s-api/endpoints/resource-quota.api.injectable"; +import limitRangeApiInjectable from "../../common/k8s-api/endpoints/limit-range.api.injectable"; +import horizontalPodAutoscalerApiInjectable from "../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable"; +import podDisruptionBudgetApiInjectable from "../../common/k8s-api/endpoints/pod-disruption-budget.api.injectable"; +import serviceApiInjectable from "../../common/k8s-api/endpoints/service.api.injectable"; +import endpointsApiInjectable from "../../common/k8s-api/endpoints/endpoint.api.injectable"; +import ingressApiInjectable from "../../common/k8s-api/endpoints/ingress.api.injectable"; +import networkPolicyApiInjectable from "../../common/k8s-api/endpoints/network-policy.api.injectable"; +import persistentVolumeApiInjectable from "../../common/k8s-api/endpoints/persistent-volume.api.injectable"; +import persistentVolumeClaimApiInjectable from "../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable"; +import storageClassApiInjectable from "../../common/k8s-api/endpoints/storage-class.api.injectable"; +import namespaceApiInjectable from "../../common/k8s-api/endpoints/namespace.api.injectable"; +import kubeEventApiInjectable from "../../common/k8s-api/endpoints/events.api.injectable"; +import roleBindingApiInjectable from "../../common/k8s-api/endpoints/role-binding.api.injectable"; +import customResourceDefinitionApiInjectable from "../../common/k8s-api/endpoints/custom-resource-definition.api.injectable"; export function isAllowedResource(resource: KubeResource | KubeResource[]) { const resources = castArray(resource); - const di = getLegacyGlobalDiForExtensionApi(); return resources.every((resourceName: any) => { @@ -20,77 +49,69 @@ export function isAllowedResource(resource: KubeResource | KubeResource[]) { }); } -export { ResourceStack } from "../../common/k8s/resource-stack"; -export { apiManager } from "../../common/k8s-api/api-manager"; -export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; -export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; -export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; -export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints"; -export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints"; -export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints"; -export { DaemonSet, daemonSetApi } from "../../common/k8s-api/endpoints"; -export { StatefulSet, statefulSetApi } from "../../common/k8s-api/endpoints"; -export { Job, jobApi } from "../../common/k8s-api/endpoints"; -export { CronJob, cronJobApi } from "../../common/k8s-api/endpoints"; -export { ConfigMap, configMapApi } from "../../common/k8s-api/endpoints"; -export { Secret, secretsApi } from "../../common/k8s-api/endpoints"; -export { ReplicaSet, replicaSetApi } from "../../common/k8s-api/endpoints"; -export { ResourceQuota, resourceQuotaApi } from "../../common/k8s-api/endpoints"; -export { LimitRange, limitRangeApi } from "../../common/k8s-api/endpoints"; -export { HorizontalPodAutoscaler, hpaApi } from "../../common/k8s-api/endpoints"; -export { PodDisruptionBudget, pdbApi } from "../../common/k8s-api/endpoints"; -export { Service, serviceApi } from "../../common/k8s-api/endpoints"; -export { Endpoint, endpointApi } from "../../common/k8s-api/endpoints"; -export { Ingress, ingressApi, IngressApi } from "../../common/k8s-api/endpoints"; -export { NetworkPolicy, networkPolicyApi } from "../../common/k8s-api/endpoints"; -export { PersistentVolume, persistentVolumeApi } from "../../common/k8s-api/endpoints"; -export { PersistentVolumeClaim, pvcApi, PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints"; -export { StorageClass, storageClassApi } from "../../common/k8s-api/endpoints"; -export { Namespace, namespacesApi } from "../../common/k8s-api/endpoints"; -export { KubeEvent, eventApi } from "../../common/k8s-api/endpoints"; -export { ServiceAccount, serviceAccountsApi } from "../../common/k8s-api/endpoints"; -export { Role, roleApi } from "../../common/k8s-api/endpoints"; -export { RoleBinding, roleBindingApi } from "../../common/k8s-api/endpoints"; -export { ClusterRole, clusterRoleApi } from "../../common/k8s-api/endpoints"; -export { ClusterRoleBinding, clusterRoleBindingApi } from "../../common/k8s-api/endpoints"; -export { CustomResourceDefinition, crdApi } from "../../common/k8s-api/endpoints"; -export { KubeObjectStatusLevel } from "./kube-object-status"; -export { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; +export const serviceAccountsApi = asLegacyGlobalForExtensionApi(serviceAccountApiInjectable); +export const clusterRoleApi = asLegacyGlobalForExtensionApi(clusterRoleApiInjectable); +export const clusterRoleBindingApi = asLegacyGlobalForExtensionApi(clusterRoleBindingApiInjectable); +export const roleApi = asLegacyGlobalForExtensionApi(roleApiInjectable); +export const podsApi = asLegacyGlobalForExtensionApi(podApiInjectable); +export const daemonSetApi = asLegacyGlobalForExtensionApi(daemonSetApiInjectable); +export const replicaSetApi = asLegacyGlobalForExtensionApi(replicaSetApiInjectable); +export const statefulSetApi = asLegacyGlobalForExtensionApi(statefulSetApiInjectable); +export const deploymentApi = asLegacyGlobalForExtensionApi(deploymentApiInjectable); +export const jobApi = asLegacyGlobalForExtensionApi(jobApiInjectable); +export const cronJobApi = asLegacyGlobalForExtensionApi(cronJobApiInjectable); +export const nodesApi = asLegacyGlobalForExtensionApi(nodeApiInjectable); +export const secretsApi = asLegacyGlobalForExtensionApi(secretApiInjectable); +export const configMapApi = asLegacyGlobalForExtensionApi(configMapApiInjectable); +export const resourceQuotaApi = asLegacyGlobalForExtensionApi(resourceQuotaApiInjectable); +export const limitRangeApi = asLegacyGlobalForExtensionApi(limitRangeApiInjectable); +export const serviceApi = asLegacyGlobalForExtensionApi(serviceApiInjectable); +export const hpaApi = asLegacyGlobalForExtensionApi(horizontalPodAutoscalerApiInjectable); +export const pdbApi = asLegacyGlobalForExtensionApi(podDisruptionBudgetApiInjectable); +export const endpointApi = asLegacyGlobalForExtensionApi(endpointsApiInjectable); +export const ingressApi = asLegacyGlobalForExtensionApi(ingressApiInjectable); +export const networkPolicyApi = asLegacyGlobalForExtensionApi(networkPolicyApiInjectable); +export const persistentVolumeApi = asLegacyGlobalForExtensionApi(persistentVolumeApiInjectable); +export const pvcApi = asLegacyGlobalForExtensionApi(persistentVolumeClaimApiInjectable); +export const storageClassApi = asLegacyGlobalForExtensionApi(storageClassApiInjectable); +export const namespacesApi = asLegacyGlobalForExtensionApi(namespaceApiInjectable); +export const eventApi = asLegacyGlobalForExtensionApi(kubeEventApiInjectable); +export const roleBindingApi = asLegacyGlobalForExtensionApi(roleBindingApiInjectable); +export const crdApi = asLegacyGlobalForExtensionApi(customResourceDefinitionApiInjectable); -// types -export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; -export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints"; -export type { ISecretRef } from "../../common/k8s-api/endpoints"; -export type { KubeObjectStatus } from "./kube-object-status"; -export type { KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; -export type { KubeObjectStoreLoadAllParams, KubeObjectStoreLoadingParams, KubeObjectStoreSubscribeParams } from "../../common/k8s-api/kube-object.store"; +export * from "../common-api/k8s-api"; + +export { + KubeObjectStatusLevel, + type KubeObjectStatus, +} from "../../common/k8s-api/kube-object-status"; // stores -export type { EventStore } from "../../renderer/components/+events/event.store"; -export type { PodsStore } from "../../renderer/components/+workloads-pods/pods.store"; -export type { NodesStore } from "../../renderer/components/+nodes/nodes.store"; -export type { DeploymentStore } from "../../renderer/components/+workloads-deployments/deployments.store"; -export type { DaemonSetStore } from "../../renderer/components/+workloads-daemonsets/daemonsets.store"; -export type { StatefulSetStore } from "../../renderer/components/+workloads-statefulsets/statefulset.store"; -export type { JobStore } from "../../renderer/components/+workloads-jobs/job.store"; -export type { CronJobStore } from "../../renderer/components/+workloads-cronjobs/cronjob.store"; -export type { ConfigMapsStore } from "../../renderer/components/+config-maps/config-maps.store"; -export type { SecretsStore } from "../../renderer/components/+config-secrets/secrets.store"; -export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/replicasets.store"; -export type { ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store"; -export type { LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store"; -export type { HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store"; -export type { PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store"; -export type { ServiceStore } from "../../renderer/components/+network-services/services.store"; -export type { EndpointStore } from "../../renderer/components/+network-endpoints/endpoints.store"; -export type { IngressStore } from "../../renderer/components/+network-ingresses/ingress.store"; -export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/network-policy.store"; -export type { PersistentVolumesStore } from "../../renderer/components/+storage-volumes/volumes.store"; -export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; -export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store"; -export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace-store/namespace.store"; -export type { ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; -export type { RolesStore } from "../../renderer/components/+user-management/+roles/store"; -export type { RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; -export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store"; -export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store"; +export type { EventStore } from "../../renderer/components/+events/store"; +export type { PodStore as PodsStore } from "../../renderer/components/+workloads-pods/store"; +export type { NodeStore as NodesStore } from "../../renderer/components/+nodes/store"; +export type { DeploymentStore } from "../../renderer/components/+workloads-deployments/store"; +export type { DaemonSetStore } from "../../renderer/components/+workloads-daemonsets/store"; +export type { StatefulSetStore } from "../../renderer/components/+workloads-statefulsets/store"; +export type { JobStore } from "../../renderer/components/+workloads-jobs/store"; +export type { CronJobStore } from "../../renderer/components/+workloads-cronjobs/store"; +export type { ConfigMapStore as ConfigMapsStore } from "../../renderer/components/+config-maps/store"; +export type { SecretStore as SecretsStore } from "../../renderer/components/+config-secrets/store"; +export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/store"; +export type { ResourceQuotaStore as ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/store"; +export type { LimitRangeStore as LimitRangesStore } from "../../renderer/components/+config-limit-ranges/store"; +export type { HorizontalPodAutoscalerStore as HPAStore } from "../../renderer/components/+config-autoscalers/store"; +export type { PodDisruptionBudgetStore as PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/store"; +export type { ServiceStore } from "../../renderer/components/+network-services/store"; +export type { EndpointsStore as EndpointStore } from "../../renderer/components/+network-endpoints/store"; +export type { IngressStore } from "../../renderer/components/+network-ingresses/store"; +export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/store"; +export type { PersistentVolumeStore as PersistentVolumesStore } from "../../renderer/components/+storage-volumes/store"; +export type { PersistentVolumeClaimStore as VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/store"; +export type { StorageClassStore } from "../../renderer/components/+storage-classes/store"; +export type { NamespaceStore } from "../../renderer/components/+namespaces/store"; +export type { ServiceAccountStore as ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; +export type { RoleStore as RolesStore } from "../../renderer/components/+user-management/+roles/store"; +export type { RoleBindingStore as RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; +export type { CustomResourceDefinitionStore as CRDStore } from "../../renderer/components/+custom-resources/definition.store"; +export type { CustomResourceStore as CRDResourceStore } from "../../common/k8s-api/api-manager/resource.store"; diff --git a/src/extensions/renderer-api/navigation.ts b/src/extensions/renderer-api/navigation.ts index 122e7c2be7..b2ea235ecc 100644 --- a/src/extensions/renderer-api/navigation.ts +++ b/src/extensions/renderer-api/navigation.ts @@ -3,14 +3,20 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { PageParamInit } from "../../renderer/navigation"; -import { navigation, PageParam } from "../../renderer/navigation"; +import getDetailsUrlInjectable from "../../renderer/components/kube-detail-params/get-details-url.injectable"; +import hideDetailsInjectable from "../../renderer/components/kube-detail-params/hide-details.injectable"; +import showDetailsInjectable from "../../renderer/components/kube-detail-params/show-details.injectable"; +import createPageParamInjectable from "../../renderer/navigation/create-page-param.injectable"; +import isActiveRouteInjectable from "../../renderer/navigation/is-route-active.injectable"; +import navigateInjectable from "../../renderer/navigation/navigate.injectable"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; -export type { PageParamInit, PageParam } from "../../renderer/navigation/page-param"; -export { navigate, isActiveRoute } from "../../renderer/navigation/helpers"; -export { hideDetails, showDetails, getDetailsUrl } from "../../renderer/components/kube-detail-params"; +export type { PageParamInit, PageParam } from "../../renderer/navigation"; export type { URLParams } from "../../common/utils/buildUrl"; -export function createPageParam(init: PageParamInit) { - return new PageParam(init, navigation); -} +export const getDetailsUrl = asLegacyGlobalFunctionForExtensionApi(getDetailsUrlInjectable); +export const showDetails = asLegacyGlobalFunctionForExtensionApi(showDetailsInjectable); +export const hideDetails = asLegacyGlobalFunctionForExtensionApi(hideDetailsInjectable); +export const createPageParam = asLegacyGlobalFunctionForExtensionApi(createPageParamInjectable); +export const isActiveRoute = asLegacyGlobalFunctionForExtensionApi(isActiveRouteInjectable); +export const navigate = asLegacyGlobalFunctionForExtensionApi(navigateInjectable); diff --git a/src/extensions/renderer-api/theming.ts b/src/extensions/renderer-api/theming.ts index 76796ab7bd..a39efc80b7 100644 --- a/src/extensions/renderer-api/theming.ts +++ b/src/extensions/renderer-api/theming.ts @@ -3,8 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ThemeStore } from "../../renderer/theme.store"; +import themeStoreInjectable from "../../renderer/themes/store.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +const themeStore = asLegacyGlobalForExtensionApi(themeStoreInjectable); export function getActiveTheme() { - return ThemeStore.getInstance().activeTheme; + return themeStore.activeTheme; } diff --git a/src/jest-after-env.setup.ts b/src/jest-after-env.setup.ts new file mode 100644 index 0000000000..b9ee36c4cf --- /dev/null +++ b/src/jest-after-env.setup.ts @@ -0,0 +1,5 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import "@testing-library/jest-dom/extend-expect"; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index d570919f3e..4086adefbd 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -3,32 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -const logger = { - silly: jest.fn(), - debug: jest.fn(), - log: jest.fn(), - info: jest.fn(), - error: jest.fn(), - crit: jest.fn(), -}; - -jest.mock("winston", () => ({ - format: { - colorize: jest.fn(), - combine: jest.fn(), - simple: jest.fn(), - label: jest.fn(), - timestamp: jest.fn(), - printf: jest.fn(), - splat: jest.fn(), - }, - createLogger: jest.fn().mockReturnValue(logger), - transports: { - Console: jest.fn(), - File: jest.fn(), - }, -})); - jest.mock("../../common/ipc"); jest.mock("request"); jest.mock("request-promise-native"); @@ -43,6 +17,13 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; +import { parse } from "url"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -50,7 +31,7 @@ describe("create clusters", () => { let cluster: Cluster; let createCluster: (model: ClusterModel) => Cluster; - beforeEach(async () => { + beforeEach(() => { jest.clearAllMocks(); const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -79,13 +60,24 @@ describe("create clusters", () => { }), }); - await di.runSetups(); - + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(directoryForTempInjectable, () => "some-directory-for-temp"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true)); di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); - di.override(createContextHandlerInjectable, () => () => { - throw new Error("you should never come here"); - }); + di.override(createContextHandlerInjectable, () => (cluster) => ({ + restartServer: jest.fn(), + stopServer: jest.fn(), + clusterUrl: parse(cluster.apiUrl), + getApiTarget: jest.fn(), + getPrometheusDetails: jest.fn(), + resolveAuthProxyCa: jest.fn(), + resolveAuthProxyUrl: jest.fn(), + setupPrometheus: jest.fn(), + ensureServer: jest.fn(), + } as ClusterContextHandler)); createCluster = di.inject(createClusterInjectionToken); @@ -99,6 +91,7 @@ describe("create clusters", () => { }); afterEach(() => { + cluster.disconnect(); mockFs.restore(); }); @@ -121,11 +114,6 @@ describe("create clusters", () => { kubeConfigPath: "minikube-config.yml", }); - cluster.contextHandler = { - ensureServer: jest.fn(), - stopServer: jest.fn(), - } as any; - jest.spyOn(cluster, "reconnect"); jest.spyOn(cluster, "refreshConnectionStatus"); diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index 7acedb7bcf..92e9d5ae25 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -4,14 +4,15 @@ */ import { UserStore } from "../../common/user-store"; -import type { ContextHandler } from "../context-handler/context-handler"; -import type { PrometheusService } from "../prometheus"; -import { PrometheusProvider, PrometheusProviderRegistry } from "../prometheus"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; +import type { PrometheusService, PrometheusProviderRegistry } from "../prometheus"; +import { PrometheusProvider } from "../prometheus"; import mockFs from "mock-fs"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; import type { Cluster } from "../../common/cluster/cluster"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; +import prometheusProviderRegistryInjectable from "../prometheus/prometheus-provider-registry.injectable"; jest.mock("electron", () => ({ app: { @@ -48,7 +49,7 @@ class TestProvider extends PrometheusProvider { throw new Error("getQuery is not implemented."); } - async getPrometheusService(): Promise { + async getPrometheusService(): Promise { switch (this.alwaysFail) { case ServiceResult.Success: return { @@ -66,16 +67,17 @@ class TestProvider extends PrometheusProvider { } const clusterStub = { - getProxyKubeconfig: (): any => ({ - makeApiClient: (): any => undefined, + getProxyKubeconfig: () => ({ + makeApiClient: (): void => undefined, }), apiUrl: "http://localhost:81", -} as Cluster; +} as unknown as Cluster; describe("ContextHandler", () => { - let createContextHandler: (cluster: Cluster) => ContextHandler; + let createContextHandler: (cluster: Cluster) => ClusterContextHandler | undefined; + let prometheusProviderRegistry: PrometheusProviderRegistry; - beforeEach(async () => { + beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs({ @@ -84,15 +86,12 @@ describe("ContextHandler", () => { di.override(createKubeAuthProxyInjectable, () => ({} as any)); - await di.runSetups(); + prometheusProviderRegistry = di.inject(prometheusProviderRegistryInjectable); createContextHandler = di.inject(createContextHandlerInjectable); - - PrometheusProviderRegistry.createInstance(); }); afterEach(() => { - PrometheusProviderRegistry.resetInstance(); UserStore.resetInstance(); mockFs.restore(); }); @@ -104,22 +103,21 @@ describe("ContextHandler", () => { [0, 2], [0, 3], ])("should throw from %d success(es) after %d failure(s)", async (successes, failures) => { - const reg = PrometheusProviderRegistry.getInstance(); let count = 0; for (let i = 0; i < failures; i += 1) { const serviceResult = i % 2 === 0 ? ServiceResult.Failure : ServiceResult.Undefined; - reg.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); } for (let i = 0; i < successes; i += 1) { - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } expect(() => { // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; return contextHandler.getPrometheusService(); }).rejects.toBeDefined(); @@ -135,21 +133,20 @@ describe("ContextHandler", () => { [2, 2], [2, 3], ])("should pick the first provider of %d success(es) after %d failure(s)", async (successes, failures) => { - const reg = PrometheusProviderRegistry.getInstance(); let count = 0; for (let i = 0; i < failures; i += 1) { const serviceResult = i % 2 === 0 ? ServiceResult.Failure : ServiceResult.Undefined; - reg.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); } for (let i = 0; i < successes; i += 1) { - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); @@ -166,21 +163,20 @@ describe("ContextHandler", () => { [2, 2], [2, 3], ])("should pick the first provider of %d success(es) before %d failure(s)", async (successes, failures) => { - const reg = PrometheusProviderRegistry.getInstance(); let count = 0; for (let i = 0; i < successes; i += 1) { - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } for (let i = 0; i < failures; i += 1) { const serviceResult = i % 2 === 0 ? ServiceResult.Failure : ServiceResult.Undefined; - reg.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); } // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); @@ -197,27 +193,26 @@ describe("ContextHandler", () => { [2, 2], [2, 3], ])("should pick the first provider of %d success(es) between %d failure(s)", async (successes, failures) => { - const reg = PrometheusProviderRegistry.getInstance(); let count = 0; const beforeSuccesses = Math.floor(successes / 2); const afterSuccesses = successes - beforeSuccesses; for (let i = 0; i < beforeSuccesses; i += 1) { - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } for (let i = 0; i < failures; i += 1) { const serviceResult = i % 2 === 0 ? ServiceResult.Failure : ServiceResult.Undefined; - reg.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); } for (let i = 0; i < afterSuccesses; i += 1) { - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); @@ -225,15 +220,14 @@ describe("ContextHandler", () => { }); it("shouldn't pick the second provider of 2 success(es) after 1 failure(s)", async () => { - const reg = PrometheusProviderRegistry.getInstance(); let count = 0; - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Failure)); - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); - reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Failure)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + prometheusProviderRegistry.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 36af031ecf..16cebb813a 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -46,7 +46,6 @@ import { mock } from "jest-mock-extended"; import { waitUntilUsed } from "tcp-port-used"; import type { Readable } from "stream"; import { EventEmitter } from "stream"; -import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; import mockFs from "mock-fs"; @@ -57,6 +56,11 @@ import path from "path"; import spawnInjectable from "../child-process/spawn.injectable"; import getConfigurationFileModelInjectable from "../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; console = new Console(stdout, stderr); @@ -99,24 +103,25 @@ describe("kube auth proxy tests", () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(directoryForTempInjectable, () => "some-directory-for-temp"); + di.override(spawnInjectable, () => mockSpawn); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); mockFs(mockMinikubeConfig); - await di.runSetups(); - createCluster = di.inject(createClusterInjectionToken); createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); - - UserStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); mockFs.restore(); }); @@ -142,69 +147,69 @@ describe("kube auth proxy tests", () => { beforeEach(async () => { mockedCP = mock(); listeners = new EventEmitter(); + const stderr = mockedCP.stderr = mock(); + const stdout = mockedCP.stdout = mock(); jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); - mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { + mockedCP.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): ChildProcess => { listeners.on(event, listener); return mockedCP; }); - mockedCP.stderr = mock(); - mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.on(`stderr/${event}`, listener); + mockedCP.stderr.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.on(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stderr/${event}`, listener); + mockedCP.stderr.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stderr/${event}`, listener); + mockedCP.stderr.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.once(`stderr/${event}`, listener); + mockedCP.stderr.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => { - listeners.removeAllListeners(event ?? `stderr/${event}`); + mockedCP.stderr.removeAllListeners.mockImplementation((event?: string | symbol): Readable => { + listeners.removeAllListeners(event ?? `stderr/${String(event)}`); - return mockedCP.stderr; + return stderr; }); - mockedCP.stdout = mock(); - mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.on(`stdout/${event}`, listener); + mockedCP.stdout.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.on(`stdout/${String(event)}`, listener); if (event === "data") { listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191"); } - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.once(`stdout/${event}`, listener); + mockedCP.stdout.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stdout/${String(event)}`, listener); - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stdout/${event}`, listener); + mockedCP.stdout.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${String(event)}`, listener); - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stdout/${event}`, listener); + mockedCP.stdout.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${String(event)}`, listener); - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => { - listeners.removeAllListeners(event ?? `stdout/${event}`); + mockedCP.stdout.removeAllListeners.mockImplementation((event?: string | symbol): Readable => { + listeners.removeAllListeners(event ?? `stdout/${String(event)}`); - return mockedCP.stdout; + return stdout; }); mockSpawn.mockImplementationOnce((command: string): ChildProcess => { expect(path.basename(command).split(".")[0]).toBe("lens-k8s-proxy"); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 91792bfaf8..604608bb25 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -2,34 +2,6 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -const logger = { - silly: jest.fn(), - debug: jest.fn(), - log: jest.fn(), - info: jest.fn(), - error: jest.fn(), - crit: jest.fn(), -}; - -jest.mock("winston", () => ({ - format: { - colorize: jest.fn(), - combine: jest.fn(), - simple: jest.fn(), - label: jest.fn(), - timestamp: jest.fn(), - padLevels: jest.fn(), - ms: jest.fn(), - printf: jest.fn(), - splat: jest.fn(), - }, - createLogger: jest.fn().mockReturnValue(logger), - transports: { - Console: jest.fn(), - File: jest.fn(), - }, -})); - import { getDiForUnitTesting } from "../getDiForUnitTesting"; import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import mockFs from "mock-fs"; @@ -43,18 +15,41 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; +import { parse } from "url"; +import loggerInjectable from "../../common/logger.injectable"; +import type { Logger } from "../../common/logger"; +import assert from "assert"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS describe("kubeconfig manager tests", () => { let clusterFake: Cluster; - let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; - let di: DiContainer; + let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager | undefined; + let di: DiContainer; + let loggerMock: jest.Mocked; beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(directoryForTempInjectable, () => "some-directory-for-temp"); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); + + loggerMock = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + silly: jest.fn(), + }; + + di.override(loggerInjectable, () => loggerMock); mockFs({ "minikube-config.yml": JSON.stringify({ @@ -80,11 +75,17 @@ describe("kubeconfig manager tests", () => { }), }); - await di.runSetups(); - - di.override(createContextHandlerInjectable, () => () => { - throw new Error("you should never come here"); - }); + di.override(createContextHandlerInjectable, () => (cluster) => ({ + restartServer: jest.fn(), + stopServer: jest.fn(), + clusterUrl: parse(cluster.apiUrl), + getApiTarget: jest.fn(), + getPrometheusDetails: jest.fn(), + resolveAuthProxyCa: jest.fn(), + resolveAuthProxyUrl: jest.fn(), + setupPrometheus: jest.fn(), + ensureServer: jest.fn(), + })); const createCluster = di.inject(createClusterInjectionToken); @@ -96,10 +97,6 @@ describe("kubeconfig manager tests", () => { kubeConfigPath: "minikube-config.yml", }); - clusterFake.contextHandler = { - ensureServer: () => Promise.resolve(), - } as any; - jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); }); @@ -110,7 +107,9 @@ describe("kubeconfig manager tests", () => { it("should create 'temp' kube config with proxy", async () => { const kubeConfManager = createKubeconfigManager(clusterFake); - expect(logger.error).not.toBeCalled(); + assert(kubeConfManager, "should actually create one"); + + expect(loggerMock.error).not.toBeCalled(); expect(await kubeConfManager.getPath()).toBe(`some-directory-for-temp${path.sep}kubeconfig-foo`); // this causes an intermittent "ENXIO: no such device or address, read" error // const file = await fse.readFile(await kubeConfManager.getPath()); @@ -125,6 +124,8 @@ describe("kubeconfig manager tests", () => { it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { const kubeConfManager = createKubeconfigManager(clusterFake); + assert(kubeConfManager, "should actually create one"); + const configPath = await kubeConfManager.getPath(); expect(await fse.pathExists(configPath)).toBe(true); diff --git a/src/main/__test__/lens-proxy.test.ts b/src/main/__test__/lens-proxy.test.ts index a80d983dec..20af162f17 100644 --- a/src/main/__test__/lens-proxy.test.ts +++ b/src/main/__test__/lens-proxy.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isLongRunningRequest } from "../lens-proxy"; +import { isLongRunningRequest } from "../lens-proxy/lens-proxy"; describe("isLongRunningRequest", () => { it("returns true on watches", () => { diff --git a/src/main/__test__/shell-session.test.ts b/src/main/__test__/shell-session.test.ts index a9cb9b93fa..1fe8915e26 100644 --- a/src/main/__test__/shell-session.test.ts +++ b/src/main/__test__/shell-session.test.ts @@ -3,10 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -/** - * @jest-environment jsdom - */ - import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; describe("clearKubeconfigEnvVars tests", () => { diff --git a/src/main/__test__/static-file-route.test.ts b/src/main/__test__/static-file-route.test.ts index bd66b0b08d..217d1effd2 100644 --- a/src/main/__test__/static-file-route.test.ts +++ b/src/main/__test__/static-file-route.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest, Route } from "../router/router"; +import type { LensApiRequest, Route } from "../router/route"; import staticFileRouteInjectable from "../routes/static-file-route.injectable"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; @@ -24,13 +24,11 @@ jest.mock("electron", () => ({ })); describe("static-file-route", () => { - let handleStaticFileRoute: Route; + let handleStaticFileRoute: Route; - beforeEach(async () => { + beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - await di.runSetups(); - handleStaticFileRoute = di.inject(staticFileRouteInjectable); }); @@ -40,7 +38,7 @@ describe("static-file-route", () => { path: "../index.ts", }, raw: {}, - } as LensApiRequest; + } as LensApiRequest<"/{path*}">; const result = await handleStaticFileRoute.handler(request); @@ -57,7 +55,7 @@ describe("static-file-route", () => { path: "router.test.ts", }, raw: { req }, - } as LensApiRequest; + } as LensApiRequest<"/{path*}">; const result = await handleStaticFileRoute.handler(request); diff --git a/src/main/app-paths/app-name/app-name.injectable.ts b/src/main/app-paths/app-name/app-name.injectable.ts index 4ba900d410..0a1db468d8 100644 --- a/src/main/app-paths/app-name/app-name.injectable.ts +++ b/src/main/app-paths/app-name/app-name.injectable.ts @@ -3,11 +3,20 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import electronAppInjectable from "../get-electron-app-path/electron-app/electron-app.injectable"; +import isDevelopmentInjectable from "../../../common/vars/is-development.injectable"; +import productNameInjectable from "./product-name.injectable"; const appNameInjectable = getInjectable({ id: "app-name", - instantiate: (di) => di.inject(electronAppInjectable).name, + + instantiate: (di) => { + const isDevelopment = di.inject(isDevelopmentInjectable); + const productName = di.inject(productNameInjectable); + + return `${productName}${isDevelopment ? "Dev" : ""}`; + }, + + causesSideEffects: true, }); export default appNameInjectable; diff --git a/src/main/app-paths/app-name/product-name.injectable.ts b/src/main/app-paths/app-name/product-name.injectable.ts new file mode 100644 index 0000000000..8c5c53bfba --- /dev/null +++ b/src/main/app-paths/app-name/product-name.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import packageInfo from "../../../../package.json"; + +const productNameInjectable = getInjectable({ + id: "product-name", + instantiate: () => packageInfo.productName, + causesSideEffects: true, +}); + +export default productNameInjectable; diff --git a/src/main/app-paths/app-paths-request-channel-listener.injectable.ts b/src/main/app-paths/app-paths-request-channel-listener.injectable.ts new file mode 100644 index 0000000000..3bd0c95bf7 --- /dev/null +++ b/src/main/app-paths/app-paths-request-channel-listener.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { RequestChannelListener } from "../../common/utils/channel/request-channel-listener-injection-token"; +import { requestChannelListenerInjectionToken } from "../../common/utils/channel/request-channel-listener-injection-token"; +import type { AppPathsChannel } from "../../common/app-paths/app-paths-channel.injectable"; +import appPathsChannelInjectable from "../../common/app-paths/app-paths-channel.injectable"; +import appPathsInjectable from "../../common/app-paths/app-paths.injectable"; + +const appPathsRequestChannelListenerInjectable = getInjectable({ + id: "app-paths-request-channel-listener", + + instantiate: (di): RequestChannelListener => { + const channel = di.inject(appPathsChannelInjectable); + const appPaths = di.inject(appPathsInjectable); + + return { + channel, + handler: () => appPaths, + }; + }, + injectionToken: requestChannelListenerInjectionToken, +}); + +export default appPathsRequestChannelListenerInjectable; diff --git a/src/main/app-paths/app-paths.injectable.ts b/src/main/app-paths/app-paths.injectable.ts deleted file mode 100644 index 342c32252d..0000000000 --- a/src/main/app-paths/app-paths.injectable.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { - DiContainerForSetup } from "@ogre-tools/injectable"; -import { - getInjectable, -} from "@ogre-tools/injectable"; - -import { - appPathsInjectionToken, - appPathsIpcChannel, -} from "../../common/app-paths/app-path-injection-token"; - -import registerChannelInjectable from "./register-channel/register-channel.injectable"; -import { getAppPaths } from "./get-app-paths"; -import getElectronAppPathInjectable from "./get-electron-app-path/get-electron-app-path.injectable"; -import setElectronAppPathInjectable from "./set-electron-app-path/set-electron-app-path.injectable"; -import appNameInjectable from "./app-name/app-name.injectable"; -import directoryForIntegrationTestingInjectable from "./directory-for-integration-testing/directory-for-integration-testing.injectable"; -import joinPathsInjectable from "../../common/path/join-paths.injectable"; - -const appPathsInjectable = getInjectable({ - id: "app-paths", - - setup: async (di) => { - const directoryForIntegrationTesting = await di.inject( - directoryForIntegrationTestingInjectable, - ); - - if (directoryForIntegrationTesting) { - await setupPathForAppDataInIntegrationTesting(di, directoryForIntegrationTesting); - } - - await setupPathForUserData(di); - await registerAppPathsChannel(di); - }, - - instantiate: (di) => - getAppPaths({ getAppPath: di.inject(getElectronAppPathInjectable) }), - - injectionToken: appPathsInjectionToken, -}); - -export default appPathsInjectable; - -const registerAppPathsChannel = async (di: DiContainerForSetup) => { - const registerChannel = await di.inject(registerChannelInjectable); - const appPaths = await di.inject(appPathsInjectable); - - registerChannel(appPathsIpcChannel, () => appPaths); -}; - -const setupPathForUserData = async (di: DiContainerForSetup) => { - const setElectronAppPath = await di.inject(setElectronAppPathInjectable); - const appName = await di.inject(appNameInjectable); - const getAppPath = await di.inject(getElectronAppPathInjectable); - const joinPaths = await di.inject(joinPathsInjectable); - - const appDataPath = getAppPath("appData"); - - setElectronAppPath("userData", joinPaths(appDataPath, appName)); -}; - -// Todo: this kludge is here only until we have a proper place to setup integration testing. -const setupPathForAppDataInIntegrationTesting = async (di: DiContainerForSetup, appDataPath: string) => { - const setElectronAppPath = await di.inject(setElectronAppPathInjectable); - - setElectronAppPath("appData", appDataPath); -}; diff --git a/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts b/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts index 76779ce5a0..73647a3270 100644 --- a/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts +++ b/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts @@ -3,10 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import environmentVariablesInjectable from "../../../common/utils/environment-variables.injectable"; const directoryForIntegrationTestingInjectable = getInjectable({ id: "directory-for-integration-testing", - instantiate: () => process.env.CICD, + + instantiate: (di) => { + const environmentVariables = di.inject(environmentVariablesInjectable); + + return environmentVariables.CICD; + }, }); export default directoryForIntegrationTestingInjectable; diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts index 4a548a1f8e..057c22c860 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts @@ -3,14 +3,15 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import electronAppInjectable from "./electron-app/electron-app.injectable"; +import electronAppInjectable from "../../electron-app/electron-app.injectable"; import { getElectronAppPath } from "./get-electron-app-path"; const getElectronAppPathInjectable = getInjectable({ id: "get-electron-app-path", - instantiate: (di) => - getElectronAppPath({ app: di.inject(electronAppInjectable) }), + instantiate: (di) => getElectronAppPath({ + app: di.inject(electronAppInjectable), + }), }); export default getElectronAppPathInjectable; diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts index 2c0d19e571..8e28c806d7 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts @@ -2,18 +2,17 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import electronAppInjectable from "./electron-app/electron-app.injectable"; +import electronAppInjectable from "../../electron-app/electron-app.injectable"; import getElectronAppPathInjectable from "./get-electron-app-path.injectable"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { App } from "electron"; -import registerChannelInjectable from "../register-channel/register-channel.injectable"; import joinPathsInjectable from "../../../common/path/join-paths.injectable"; import { joinPathsFake } from "../../../common/test-utils/join-paths-fake"; describe("get-electron-app-path", () => { - let getElectronAppPath: (name: string) => string | null; + let getElectronAppPath: (name: string) => string; - beforeEach(async () => { + beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: false }); const appStub = { @@ -32,12 +31,9 @@ describe("get-electron-app-path", () => { } as App; di.override(electronAppInjectable, () => appStub); - di.override(registerChannelInjectable, () => () => undefined); di.override(joinPathsInjectable, () => joinPathsFake); - await di.runSetups(); - - getElectronAppPath = di.inject(getElectronAppPathInjectable); + getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string; }); it("given app path exists, when called, returns app path", () => { diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts index cbd70cc02c..f7531b89b1 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts @@ -9,12 +9,16 @@ interface Dependencies { app: App; } -export const getElectronAppPath = - ({ app }: Dependencies) => - (name: PathName) : string | null => { - try { - return app.getPath(name); - } catch (e) { - return ""; - } - }; +export type GetElectronAppPath = (name: PathName) => string; + +export const getElectronAppPath = ({ + app, +}: Dependencies): GetElectronAppPath => ( + (name) => { + try { + return app.getPath(name); + } catch (e) { + return ""; + } + } +); diff --git a/src/main/app-paths/register-channel/register-channel.injectable.ts b/src/main/app-paths/register-channel/register-channel.injectable.ts deleted file mode 100644 index d0b517cf25..0000000000 --- a/src/main/app-paths/register-channel/register-channel.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import ipcMainInjectable from "./ipc-main/ipc-main.injectable"; -import { registerChannel } from "./register-channel"; - -const registerChannelInjectable = getInjectable({ - id: "register-channel", - - instantiate: (di) => registerChannel({ - ipcMain: di.inject(ipcMainInjectable), - }), -}); - -export default registerChannelInjectable; diff --git a/src/main/app-paths/register-channel/register-channel.ts b/src/main/app-paths/register-channel/register-channel.ts deleted file mode 100644 index 73f3e13243..0000000000 --- a/src/main/app-paths/register-channel/register-channel.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { IpcMain } from "electron"; -import type { Channel } from "../../../common/ipc-channel/channel"; - -interface Dependencies { - ipcMain: IpcMain; -} - -export const registerChannel = - ({ ipcMain }: Dependencies) => - , TInstance>( - channel: TChannel, - getValue: () => TInstance, - ) => - ipcMain.handle(channel.name, getValue); diff --git a/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts b/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts index 636063d939..73090956d3 100644 --- a/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts +++ b/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import type { PathName } from "../../../common/app-paths/app-path-names"; -import electronAppInjectable from "../get-electron-app-path/electron-app/electron-app.injectable"; +import electronAppInjectable from "../../electron-app/electron-app.injectable"; const setElectronAppPathInjectable = getInjectable({ id: "set-electron-app-path", diff --git a/src/main/app-paths/setup-app-paths.injectable.ts b/src/main/app-paths/setup-app-paths.injectable.ts new file mode 100644 index 0000000000..816c58db8b --- /dev/null +++ b/src/main/app-paths/setup-app-paths.injectable.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AppPaths } from "../../common/app-paths/app-path-injection-token"; +import getElectronAppPathInjectable from "./get-electron-app-path/get-electron-app-path.injectable"; +import setElectronAppPathInjectable from "./set-electron-app-path/set-electron-app-path.injectable"; +import appNameInjectable from "./app-name/app-name.injectable"; +import directoryForIntegrationTestingInjectable from "./directory-for-integration-testing/directory-for-integration-testing.injectable"; +import appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable"; +import { pathNames } from "../../common/app-paths/app-path-names"; +import { fromPairs, map } from "lodash/fp"; +import { pipeline } from "@ogre-tools/fp"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import { beforeElectronIsReadyInjectionToken } from "../start-main-application/runnable-tokens/before-electron-is-ready-injection-token"; + +const setupAppPathsInjectable = getInjectable({ + id: "setup-app-paths", + + instantiate: (di) => { + const setElectronAppPath = di.inject(setElectronAppPathInjectable); + const appName = di.inject(appNameInjectable); + const getAppPath = di.inject(getElectronAppPathInjectable); + const appPathsState = di.inject(appPathsStateInjectable); + const directoryForIntegrationTesting = di.inject(directoryForIntegrationTestingInjectable); + const joinPaths = di.inject(joinPathsInjectable); + + return { + run: () => { + if (directoryForIntegrationTesting) { + setElectronAppPath("appData", directoryForIntegrationTesting); + } + + const appDataPath = getAppPath("appData"); + + setElectronAppPath("userData", joinPaths(appDataPath, appName)); + + const appPaths = pipeline( + pathNames, + map(name => [name, getAppPath(name)]), + fromPairs, + ) as AppPaths; + + appPathsState.set(appPaths); + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupAppPathsInjectable; diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts deleted file mode 100644 index 0b7a5609b8..0000000000 --- a/src/main/app-updater.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { UpdateInfo } from "electron-updater"; -import { autoUpdater } from "electron-updater"; -import logger from "./logger"; -import { isPublishConfigured, isTestEnv } from "../common/vars"; -import { delay } from "../common/utils"; -import type { UpdateAvailableToBackchannel } from "../common/ipc"; -import { areArgsUpdateAvailableToBackchannel, AutoUpdateChecking, AutoUpdateLogPrefix, AutoUpdateNoUpdateAvailable, broadcastMessage, onceCorrect, UpdateAvailableChannel } from "../common/ipc"; -import { once } from "lodash"; -import { ipcMain } from "electron"; -import { nextUpdateChannel } from "./utils/update-channel"; -import { UserStore } from "../common/user-store"; - -let installVersion: null | string = null; - -export function isAutoUpdateEnabled() { - return autoUpdater.isUpdaterActive() && isPublishConfigured; -} - -function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) { - if (arg.doUpdate) { - if (arg.now) { - logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); - autoUpdater.quitAndInstall(true, true); - } else { - logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); - autoUpdater.autoInstallOnAppQuit = true; - } - } else { - logger.info(`${AutoUpdateLogPrefix}: User chose not to update`); - } -} - -autoUpdater.logger = { - info: message => logger.info(`[AUTO-UPDATE]: electron-updater: %s`, message), - warn: message => logger.warn(`[AUTO-UPDATE]: electron-updater: %s`, message), - error: message => logger.error(`[AUTO-UPDATE]: electron-updater: %s`, message), - debug: message => logger.debug(`[AUTO-UPDATE]: electron-updater: %s`, message), -}; - -/** - * starts the automatic update checking - * @param interval milliseconds between interval to check on, defaults to 24h - */ -export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void { - if (!isAutoUpdateEnabled() || isTestEnv) { - return; - } - - const userStore = UserStore.getInstance(); - - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - autoUpdater.channel = userStore.updateChannel; - autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade; - - autoUpdater - .on("update-available", (info: UpdateInfo) => { - if (autoUpdater.autoInstallOnAppQuit) { - // a previous auto-update loop was completed with YES+LATER, check if same version - if (installVersion === info.version) { - // same version, don't broadcast - return; - } - } - - /** - * This should be always set to false here because it is the reasonable - * default. Namely, if a don't auto update to a version that the user - * didn't ask for. - */ - autoUpdater.autoInstallOnAppQuit = false; - installVersion = info.version; - - autoUpdater.downloadUpdate() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) })); - }) - .on("update-downloaded", (info: UpdateInfo) => { - try { - const backchannel = `auto-update:${info.version}`; - - ipcMain.removeAllListeners(backchannel); // only one handler should be present - - // make sure that the handler is in place before broadcasting (prevent race-condition) - onceCorrect({ - source: ipcMain, - channel: backchannel, - listener: handleAutoUpdateBackChannel, - verifier: areArgsUpdateAvailableToBackchannel, - }); - logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version }); - broadcastMessage(UpdateAvailableChannel, backchannel, info); - } catch (error) { - logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); - installVersion = undefined; - } - }) - .on("update-not-available", () => { - const nextChannel = nextUpdateChannel(userStore.updateChannel, autoUpdater.channel); - - logger.info(`${AutoUpdateLogPrefix}: update not available from ${autoUpdater.channel}, will check ${nextChannel} channel next`); - - if (nextChannel !== autoUpdater.channel) { - autoUpdater.channel = nextChannel; - autoUpdater.checkForUpdates() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed with an error`, error)); - } else { - broadcastMessage(AutoUpdateNoUpdateAvailable); - } - }); - - async function helper() { - while (true) { - await checkForUpdates(); - await delay(interval); - } - } - - helper(); -}); - -export async function checkForUpdates(): Promise { - const userStore = UserStore.getInstance(); - - try { - logger.info(`📡 Checking for app updates`); - - autoUpdater.channel = userStore.updateChannel; - autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade; - broadcastMessage(AutoUpdateChecking); - await autoUpdater.checkForUpdates(); - } catch (error) { - logger.error(`${AutoUpdateLogPrefix}: failed with an error`, error); - } -} diff --git a/src/main/application-update/check-for-platform-updates/check-for-platform-updates.injectable.ts b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.injectable.ts new file mode 100644 index 0000000000..286999c484 --- /dev/null +++ b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.injectable.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import type { UpdateChannel } from "../../../common/application-update/update-channels"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { UpdateCheckResult } from "electron-updater"; + +export type CheckForUpdatesResult = { + updateWasDiscovered: false; +} | { + updateWasDiscovered: true; + version: string; +}; + +export type CheckForPlatformUpdates = (updateChannel: UpdateChannel, opts: { allowDowngrade: boolean }) => Promise; + +const checkForPlatformUpdatesInjectable = getInjectable({ + id: "check-for-platform-updates", + + instantiate: (di): CheckForPlatformUpdates => { + const electronUpdater = di.inject(electronUpdaterInjectable); + const logger = di.inject(loggerInjectable); + + return async (updateChannel, { allowDowngrade }) => { + electronUpdater.channel = updateChannel.id; + electronUpdater.autoDownload = false; + electronUpdater.allowDowngrade = allowDowngrade; + + let result: UpdateCheckResult; + + try { + result = await electronUpdater.checkForUpdates(); + } catch (error) { + logger.error("[UPDATE-APP/CHECK-FOR-UPDATES]", error); + + return { + updateWasDiscovered: false, + }; + } + + const { updateInfo, cancellationToken } = result; + + if (!cancellationToken) { + return { + updateWasDiscovered: false, + }; + } + + return { + updateWasDiscovered: true, + version: updateInfo.version, + }; + }; + }, +}); + +export default checkForPlatformUpdatesInjectable; diff --git a/src/main/application-update/check-for-platform-updates/check-for-platform-updates.test.ts b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.test.ts new file mode 100644 index 0000000000..b826a1a5a7 --- /dev/null +++ b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.test.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { AppUpdater, UpdateCheckResult } from "electron-updater"; +import type { CheckForPlatformUpdates } from "./check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "./check-for-platform-updates.injectable"; +import type { UpdateChannel, UpdateChannelId } from "../../../common/application-update/update-channels"; +import { getPromiseStatus } from "../../../common/test-utils/get-promise-status"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { Logger } from "../../../common/logger"; + +describe("check-for-platform-updates", () => { + let checkForPlatformUpdates: CheckForPlatformUpdates; + let electronUpdaterFake: AppUpdater; + let checkForUpdatesMock: AsyncFnMock<() => UpdateCheckResult>; + let logErrorMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + checkForUpdatesMock = asyncFn(); + + electronUpdaterFake = { + channel: undefined, + autoDownload: undefined, + allowDowngrade: undefined, + + checkForUpdates: checkForUpdatesMock, + } as unknown as AppUpdater; + + di.override(electronUpdaterInjectable, () => electronUpdaterFake); + + logErrorMock = jest.fn(); + + di.override(loggerInjectable, () => ({ error: logErrorMock }) as unknown as Logger); + + checkForPlatformUpdates = di.inject(checkForPlatformUpdatesInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise; + + beforeEach(() => { + const testUpdateChannel: UpdateChannel = { + id: "some-update-channel" as UpdateChannelId, + label: "Some update channel", + moreStableUpdateChannel: null, + }; + + actualPromise = checkForPlatformUpdates(testUpdateChannel, { allowDowngrade: true }); + }); + + it("sets update channel", () => { + expect(electronUpdaterFake.channel).toBe("some-update-channel"); + }); + + it("sets flag for allowing downgrade", () => { + expect(electronUpdaterFake.allowDowngrade).toBe(true); + }); + + it("disables auto downloading for being controlled", () => { + expect(electronUpdaterFake.autoDownload).toBe(false); + }); + + it("checks for updates", () => { + expect(checkForUpdatesMock).toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when checking for updates resolves with update, resolves with the discovered update", async () => { + await checkForUpdatesMock.resolve({ + updateInfo: { + version: "some-version", + }, + + cancellationToken: "some-cancellation-token", + } as unknown as UpdateCheckResult); + + const actual = await actualPromise; + + expect(actual).toEqual({ updateWasDiscovered: true, version: "some-version" }); + }); + + it("when checking for updates resolves without update, resolves with update not being discovered", async () => { + await checkForUpdatesMock.resolve({ + updateInfo: { + version: "some-version-that-matches-to-current-installed-version", + }, + + cancellationToken: null, + } as unknown as UpdateCheckResult); + + const actual = await actualPromise; + + expect(actual).toEqual({ updateWasDiscovered: false }); + }); + + describe("when checking for updates rejects", () => { + let errorStub: Error; + + beforeEach(() => { + errorStub = new Error("Some error"); + + checkForUpdatesMock.reject(errorStub); + }); + + it("logs errors", () => { + expect(logErrorMock).toHaveBeenCalledWith("[UPDATE-APP/CHECK-FOR-UPDATES]", errorStub); + }); + + it("resolves with update not being discovered", async () => { + const actual = await actualPromise; + + expect(actual).toEqual({ updateWasDiscovered: false }); + }); + }); + }); +}); diff --git a/src/main/application-update/check-for-updates-tray-item.injectable.ts b/src/main/application-update/check-for-updates-tray-item.injectable.ts new file mode 100644 index 0000000000..5ff4be731a --- /dev/null +++ b/src/main/application-update/check-for-updates-tray-item.injectable.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import updatingIsEnabledInjectable from "./updating-is-enabled.injectable"; +import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; +import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; +import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import updateIsBeingDownloadedInjectable from "../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import updatesAreBeingDiscoveredInjectable from "../../common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable"; +import progressOfUpdateDownloadInjectable from "../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; +import assert from "assert"; +import processCheckingForUpdatesInjectable from "./check-for-updates/process-checking-for-updates.injectable"; +import { withErrorSuppression } from "../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../common/utils/with-error-logging/with-error-logging.injectable"; + +const checkForUpdatesTrayItemInjectable = getInjectable({ + id: "check-for-updates-tray-item", + + instantiate: (di) => { + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); + const progressOfUpdateDownload = di.inject(progressOfUpdateDownloadInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const downloadingUpdateState = di.inject(updateIsBeingDownloadedInjectable); + const checkingForUpdatesState = di.inject(updatesAreBeingDiscoveredInjectable); + const processCheckingForUpdates = di.inject(processCheckingForUpdatesInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "check-for-updates", + parentId: null, + orderNumber: 30, + + label: computed(() => { + if (downloadingUpdateState.value.get()) { + const discoveredVersion = discoveredVersionState.value.get(); + + assert(discoveredVersion); + + const roundedPercentage = Math.round(progressOfUpdateDownload.value.get().percentage); + + return `Downloading update ${discoveredVersion.version} (${roundedPercentage}%)...`; + } + + if (checkingForUpdatesState.value.get()) { + return "Checking for updates..."; + } + + return "Check for updates"; + }), + + enabled: computed(() => !checkingForUpdatesState.value.get() && !downloadingUpdateState.value.get()), + + visible: computed(() => updatingIsEnabled), + + click: pipeline( + async () => { + await processCheckingForUpdates(); + + await showApplicationWindow(); + }, + + withErrorLoggingFor(() => "[TRAY]: Checking for updates failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default checkForUpdatesTrayItemInjectable; diff --git a/src/main/application-update/check-for-updates/broadcast-change-in-updating-status.injectable.ts b/src/main/application-update/check-for-updates/broadcast-change-in-updating-status.injectable.ts new file mode 100644 index 0000000000..7e9257e966 --- /dev/null +++ b/src/main/application-update/check-for-updates/broadcast-change-in-updating-status.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ApplicationUpdateStatusChannelMessage } from "../../../common/application-update/application-update-status-channel.injectable"; +import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import applicationUpdateStatusChannelInjectable from "../../../common/application-update/application-update-status-channel.injectable"; + +const broadcastChangeInUpdatingStatusInjectable = getInjectable({ + id: "broadcast-change-in-updating-status", + + instantiate: (di) => { + const messageToChannel = di.inject(messageToChannelInjectionToken); + const applicationUpdateStatusChannel = di.inject(applicationUpdateStatusChannelInjectable); + + return (data: ApplicationUpdateStatusChannelMessage) => { + messageToChannel(applicationUpdateStatusChannel, data); + }; + }, +}); + +export default broadcastChangeInUpdatingStatusInjectable; diff --git a/src/main/application-update/check-for-updates/check-for-updates-starting-from-channel.injectable.ts b/src/main/application-update/check-for-updates/check-for-updates-starting-from-channel.injectable.ts new file mode 100644 index 0000000000..caf695ff80 --- /dev/null +++ b/src/main/application-update/check-for-updates/check-for-updates-starting-from-channel.injectable.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { UpdateChannel } from "../../../common/application-update/update-channels"; +import checkForPlatformUpdatesInjectable from "../check-for-platform-updates/check-for-platform-updates.injectable"; +import updateCanBeDowngradedInjectable from "./update-can-be-downgraded.injectable"; + +export type CheckForUpdatesFromChannelResult = { + updateWasDiscovered: false; +} | { + updateWasDiscovered: true; + version: string; + actualUpdateChannel: UpdateChannel; +}; + +const checkForUpdatesStartingFromChannelInjectable = getInjectable({ + id: "check-for-updates-starting-from-channel", + + instantiate: (di) => { + const checkForPlatformUpdates = di.inject( + checkForPlatformUpdatesInjectable, + ); + + const updateCanBeDowngraded = di.inject(updateCanBeDowngradedInjectable); + + const _recursiveCheck = async ( + updateChannel: UpdateChannel, + ): Promise => { + const result = await checkForPlatformUpdates(updateChannel, { + allowDowngrade: updateCanBeDowngraded.get(), + }); + + if (result.updateWasDiscovered) { + return { + updateWasDiscovered: true, + version: result.version, + actualUpdateChannel: updateChannel, + }; + } + + if (updateChannel.moreStableUpdateChannel) { + return await _recursiveCheck(updateChannel.moreStableUpdateChannel); + } + + return { updateWasDiscovered: false }; + }; + + return _recursiveCheck; + }, +}); + +export default checkForUpdatesStartingFromChannelInjectable; diff --git a/src/main/application-update/check-for-updates/process-checking-for-updates.injectable.ts b/src/main/application-update/check-for-updates/process-checking-for-updates.injectable.ts new file mode 100644 index 0000000000..2688d00d4a --- /dev/null +++ b/src/main/application-update/check-for-updates/process-checking-for-updates.injectable.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import updatesAreBeingDiscoveredInjectable from "../../../common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import { runInAction } from "mobx"; +import askBooleanInjectable from "../../ask-boolean/ask-boolean.injectable"; +import quitAndInstallUpdateInjectable from "../../electron-app/features/quit-and-install-update.injectable"; +import downloadUpdateInjectable from "../download-update/download-update.injectable"; +import broadcastChangeInUpdatingStatusInjectable from "./broadcast-change-in-updating-status.injectable"; +import checkForUpdatesStartingFromChannelInjectable from "./check-for-updates-starting-from-channel.injectable"; +import withOrphanPromiseInjectable from "../../../common/utils/with-orphan-promise/with-orphan-promise.injectable"; + +const processCheckingForUpdatesInjectable = getInjectable({ + id: "process-checking-for-updates", + + instantiate: (di) => { + const askBoolean = di.inject(askBooleanInjectable); + const quitAndInstallUpdate = di.inject(quitAndInstallUpdateInjectable); + const downloadUpdate = di.inject(downloadUpdateInjectable); + const selectedUpdateChannel = di.inject(selectedUpdateChannelInjectable); + const broadcastChangeInUpdatingStatus = di.inject(broadcastChangeInUpdatingStatusInjectable); + const checkingForUpdatesState = di.inject(updatesAreBeingDiscoveredInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const checkForUpdatesStartingFromChannel = di.inject(checkForUpdatesStartingFromChannelInjectable); + const withOrphanPromise = di.inject(withOrphanPromiseInjectable); + + return async () => { + broadcastChangeInUpdatingStatus({ eventId: "checking-for-updates" }); + + runInAction(() => { + checkingForUpdatesState.set(true); + }); + + const result = await checkForUpdatesStartingFromChannel(selectedUpdateChannel.value.get()); + + if (!result.updateWasDiscovered) { + broadcastChangeInUpdatingStatus({ eventId: "no-updates-available" }); + + runInAction(() => { + discoveredVersionState.set(null); + checkingForUpdatesState.set(false); + }); + + return; + } + + const { version, actualUpdateChannel } = result; + + broadcastChangeInUpdatingStatus({ + eventId: "download-for-update-started", + version, + }); + + runInAction(() => { + discoveredVersionState.set({ + version, + updateChannel: actualUpdateChannel, + }); + + checkingForUpdatesState.set(false); + }); + + withOrphanPromise(async () => { + const { downloadWasSuccessful } = await downloadUpdate(); + + if (!downloadWasSuccessful) { + broadcastChangeInUpdatingStatus({ + eventId: "download-for-update-failed", + }); + + return; + } + + const userWantsToInstallUpdate = await askBoolean({ + title: "Update Available", + + question: `Version ${version} of Lens IDE is available and ready to be installed. Would you like to update now?\n\n` + + `Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating.`, + }); + + if (userWantsToInstallUpdate) { + quitAndInstallUpdate(); + } + })(); + }; + }, +}); + +export default processCheckingForUpdatesInjectable; diff --git a/src/main/application-update/check-for-updates/update-can-be-downgraded.injectable.ts b/src/main/application-update/check-for-updates/update-can-be-downgraded.injectable.ts new file mode 100644 index 0000000000..72ea5c4023 --- /dev/null +++ b/src/main/application-update/check-for-updates/update-can-be-downgraded.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import { SemVer } from "semver"; + +const updateCanBeDowngradedInjectable = getInjectable({ + id: "update-can-be-downgraded", + + instantiate: (di) => { + const selectedUpdateChannel = di.inject(selectedUpdateChannelInjectable); + const appVersion = di.inject(appVersionInjectable); + + return computed(() => { + const semVer = new SemVer(appVersion); + + return ( + semVer.prerelease[0] !== + selectedUpdateChannel.value.get().id + ); + }); + }, +}); + +export default updateCanBeDowngradedInjectable; diff --git a/src/main/application-update/download-platform-update/download-platform-update.injectable.ts b/src/main/application-update/download-platform-update/download-platform-update.injectable.ts new file mode 100644 index 0000000000..374efd2caf --- /dev/null +++ b/src/main/application-update/download-platform-update/download-platform-update.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { ProgressInfo } from "electron-updater"; +import type { ProgressOfDownload } from "../../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; + +export type DownloadPlatformUpdate = ( + onDownloadProgress: (arg: ProgressOfDownload) => void +) => Promise<{ downloadWasSuccessful: boolean }>; + +const downloadPlatformUpdateInjectable = getInjectable({ + id: "download-platform-update", + + instantiate: (di): DownloadPlatformUpdate => { + const electronUpdater = di.inject(electronUpdaterInjectable); + const logger = di.inject(loggerInjectable); + + return async (onDownloadProgress) => { + onDownloadProgress({ percentage: 0 }); + + const updateDownloadProgress = ({ percent: percentage }: ProgressInfo) => + onDownloadProgress({ percentage }); + + electronUpdater.on("download-progress", updateDownloadProgress); + + try { + await electronUpdater.downloadUpdate(); + } catch(error) { + logger.error("[UPDATE-APP/DOWNLOAD]", error); + + return { downloadWasSuccessful: false }; + } finally { + electronUpdater.off("download-progress", updateDownloadProgress); + } + + return { downloadWasSuccessful: true }; + }; + }, +}); + +export default downloadPlatformUpdateInjectable; diff --git a/src/main/application-update/download-platform-update/download-platform-update.test.ts b/src/main/application-update/download-platform-update/download-platform-update.test.ts new file mode 100644 index 0000000000..04507be980 --- /dev/null +++ b/src/main/application-update/download-platform-update/download-platform-update.test.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import type { DownloadPlatformUpdate } from "./download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "./download-platform-update.injectable"; +import type { AppUpdater } from "electron-updater"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../../common/test-utils/get-promise-status"; +import type { DiContainer } from "@ogre-tools/injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { Logger } from "../../../common/logger"; + +describe("download-platform-update", () => { + let downloadPlatformUpdate: DownloadPlatformUpdate; + let downloadUpdateMock: AsyncFnMock<() => void>; + let electronUpdaterFake: AppUpdater; + let electronUpdaterOnMock: jest.Mock; + let electronUpdaterOffMock: jest.Mock; + let di: DiContainer; + let logErrorMock: jest.Mock; + + beforeEach(() => { + di = getDiForUnitTesting(); + + downloadUpdateMock = asyncFn(); + electronUpdaterOnMock = jest.fn(); + electronUpdaterOffMock = jest.fn(); + + electronUpdaterFake = { + channel: undefined, + autoDownload: undefined, + + on: electronUpdaterOnMock, + off: electronUpdaterOffMock, + + downloadUpdate: downloadUpdateMock, + } as unknown as AppUpdater; + + di.override(electronUpdaterInjectable, () => electronUpdaterFake); + + logErrorMock = jest.fn(); + di.override(loggerInjectable, () => ({ error: logErrorMock }) as unknown as Logger); + + downloadPlatformUpdate = di.inject(downloadPlatformUpdateInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise<{ downloadWasSuccessful: boolean }>; + let onDownloadProgressMock: jest.Mock; + + beforeEach(() => { + onDownloadProgressMock = jest.fn(); + + actualPromise = downloadPlatformUpdate(onDownloadProgressMock); + }); + + it("calls for downloading of update", () => { + expect(downloadUpdateMock).toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("starts progress of download from 0", () => { + expect(onDownloadProgressMock).toHaveBeenCalledWith({ percentage: 0 }); + }); + + describe("when downloading progresses", () => { + beforeEach(() => { + onDownloadProgressMock.mockClear(); + + const [, callback] = electronUpdaterOnMock.mock.calls.find( + ([event]) => event === "download-progress", + ); + + callback({ + percent: 42, + total: 0, + delta: 0, + transferred: 0, + bytesPerSecond: 0, + }); + }); + + it("updates progress of the download", () => { + expect(onDownloadProgressMock).toHaveBeenCalledWith({ percentage: 42 }); + }); + + describe("when downloading resolves", () => { + beforeEach(async () => { + onDownloadProgressMock.mockClear(); + + await downloadUpdateMock.resolve(); + }); + + it("resolves with success", async () => { + const actual = await actualPromise; + + expect(actual).toEqual({ downloadWasSuccessful: true }); + }); + + it("does not reset progress of download yet", () => { + expect(onDownloadProgressMock).not.toHaveBeenCalled(); + }); + + it("stops watching for download progress", () => { + expect(electronUpdaterOffMock).toHaveBeenCalledWith( + "download-progress", + expect.any(Function), + ); + }); + + it("when starting download again, resets progress of download", () => { + downloadPlatformUpdate(onDownloadProgressMock); + + expect(onDownloadProgressMock).toHaveBeenCalledWith({ percentage: 0 }); + }); + }); + + describe("when downloading rejects", () => { + let errorStub: Error; + + beforeEach(() => { + errorStub = new Error("Some error"); + + downloadUpdateMock.reject(errorStub); + }); + + it("logs error", () => { + expect(logErrorMock).toHaveBeenCalledWith("[UPDATE-APP/DOWNLOAD]", errorStub); + }); + + it("stops watching for download progress", () => { + expect(electronUpdaterOffMock).toHaveBeenCalledWith( + "download-progress", + expect.any(Function), + ); + }); + + it("resolves with failure", async () => { + const actual = await actualPromise; + + expect(actual).toEqual({ downloadWasSuccessful: false }); + }); + }); + }); + }); +}); diff --git a/src/main/application-update/download-update/download-update.injectable.ts b/src/main/application-update/download-update/download-update.injectable.ts new file mode 100644 index 0000000000..d0ac141b9c --- /dev/null +++ b/src/main/application-update/download-update/download-update.injectable.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import downloadPlatformUpdateInjectable from "../download-platform-update/download-platform-update.injectable"; +import updateIsBeingDownloadedInjectable from "../../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import { action, runInAction } from "mobx"; +import type { ProgressOfDownload } from "../../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; +import progressOfUpdateDownloadInjectable from "../../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; + +const downloadUpdateInjectable = getInjectable({ + id: "download-update", + + instantiate: (di) => { + const downloadPlatformUpdate = di.inject(downloadPlatformUpdateInjectable); + const downloadingUpdateState = di.inject(updateIsBeingDownloadedInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const progressOfUpdateDownload = di.inject(progressOfUpdateDownloadInjectable); + + const updateDownloadProgress = action((progressOfDownload: ProgressOfDownload) => { + progressOfUpdateDownload.set(progressOfDownload); + }); + + return async () => { + runInAction(() => { + progressOfUpdateDownload.set({ percentage: 0 }); + downloadingUpdateState.set(true); + }); + + const { downloadWasSuccessful } = await downloadPlatformUpdate( + updateDownloadProgress, + ); + + runInAction(() => { + if (!downloadWasSuccessful) { + discoveredVersionState.set(null); + } + + downloadingUpdateState.set(false); + }); + + return { downloadWasSuccessful }; + }; + }, +}); + +export default downloadUpdateInjectable; diff --git a/src/main/application-update/install-application-update-tray-item.injectable.ts b/src/main/application-update/install-application-update-tray-item.injectable.ts new file mode 100644 index 0000000000..2f938964f8 --- /dev/null +++ b/src/main/application-update/install-application-update-tray-item.injectable.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; +import quitAndInstallUpdateInjectable from "../electron-app/features/quit-and-install-update.injectable"; +import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import updateIsBeingDownloadedInjectable from "../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import { withErrorSuppression } from "../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../common/utils/with-error-logging/with-error-logging.injectable"; + +const installApplicationUpdateTrayItemInjectable = getInjectable({ + id: "install-update-tray-item", + + instantiate: (di) => { + const quitAndInstallUpdate = di.inject(quitAndInstallUpdateInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const downloadingUpdateState = di.inject(updateIsBeingDownloadedInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "install-update", + parentId: null, + orderNumber: 50, + + label: computed(() => { + const versionToBeInstalled = discoveredVersionState.value.get()?.version; + + return `Install update ${versionToBeInstalled}`; + }), + + enabled: computed(() => true), + + visible: computed( + () => !!discoveredVersionState.value.get() && !downloadingUpdateState.value.get(), + ), + + click: pipeline( + quitAndInstallUpdate, + + withErrorLoggingFor(() => "[TRAY]: Update installation failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default installApplicationUpdateTrayItemInjectable; diff --git a/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts b/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts new file mode 100644 index 0000000000..394383ee65 --- /dev/null +++ b/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import processCheckingForUpdatesInjectable from "../check-for-updates/process-checking-for-updates.injectable"; + +const periodicalCheckForUpdatesInjectable = getInjectable({ + id: "periodical-check-for-updates", + + instantiate: (di) => { + const processCheckingForUpdates = di.inject(processCheckingForUpdatesInjectable); + + return getStartableStoppable("periodical-check-for-updates", () => { + const TWO_HOURS = 1000 * 60 * 60 * 2; + + // Note: intentional orphan promise to make checking for updates happen in the background + processCheckingForUpdates(); + + const intervalId = setInterval(() => { + // Note: intentional orphan promise to make checking for updates happen in the background + processCheckingForUpdates(); + }, TWO_HOURS); + + return () => { + clearInterval(intervalId); + }; + }); + }, + + causesSideEffects: true, +}); + +export default periodicalCheckForUpdatesInjectable; diff --git a/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts b/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts new file mode 100644 index 0000000000..9a9b9cf206 --- /dev/null +++ b/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import periodicalCheckForUpdatesInjectable from "./periodical-check-for-updates.injectable"; +import { afterRootFrameIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token"; +import updatingIsEnabledInjectable from "../updating-is-enabled.injectable"; + +const startCheckingForUpdatesInjectable = getInjectable({ + id: "start-checking-for-updates", + + instantiate: (di) => { + const periodicalCheckForUpdates = di.inject(periodicalCheckForUpdatesInjectable); + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); + + return { + run: async () => { + if (updatingIsEnabled) { + await periodicalCheckForUpdates.start(); + } + }, + }; + }, + + injectionToken: afterRootFrameIsReadyInjectionToken, +}); + +export default startCheckingForUpdatesInjectable; diff --git a/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts b/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts new file mode 100644 index 0000000000..13aefe4d96 --- /dev/null +++ b/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token"; +import periodicalCheckForUpdatesInjectable from "./periodical-check-for-updates.injectable"; + +const stopCheckingForUpdatesInjectable = getInjectable({ + id: "stop-checking-for-updates", + + instantiate: (di) => { + const periodicalCheckForUpdates = di.inject(periodicalCheckForUpdatesInjectable); + + return { + run: async () => { + if (periodicalCheckForUpdates.started) { + await periodicalCheckForUpdates.stop(); + } + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default stopCheckingForUpdatesInjectable; diff --git a/src/main/application-update/publish-is-configured.injectable.ts b/src/main/application-update/publish-is-configured.injectable.ts new file mode 100644 index 0000000000..321adc8a22 --- /dev/null +++ b/src/main/application-update/publish-is-configured.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import packageJsonInjectable from "../../common/vars/package-json.injectable"; +import { has } from "lodash/fp"; + +// TOOO: Rename to something less technical +const publishIsConfiguredInjectable = getInjectable({ + id: "publish-is-configured", + + instantiate: (di) => { + const packageJson = di.inject(packageJsonInjectable); + + return has("build.publish", packageJson); + }, +}); + +export default publishIsConfiguredInjectable; diff --git a/src/main/application-update/updating-is-enabled.injectable.ts b/src/main/application-update/updating-is-enabled.injectable.ts new file mode 100644 index 0000000000..df5e264219 --- /dev/null +++ b/src/main/application-update/updating-is-enabled.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronUpdaterIsActiveInjectable from "../electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "./publish-is-configured.injectable"; + +const updatingIsEnabledInjectable = getInjectable({ + id: "updating-is-enabled", + + instantiate: (di) => { + const electronUpdaterIsActive = di.inject(electronUpdaterIsActiveInjectable); + const publishIsConfigured = di.inject(publishIsConfiguredInjectable); + + return electronUpdaterIsActive && publishIsConfigured; + }, +}); + +export default updatingIsEnabledInjectable; diff --git a/src/main/application-update/watch-if-update-should-happen-on-quit/start-watching-if-update-should-happen-on-quit.injectable.ts b/src/main/application-update/watch-if-update-should-happen-on-quit/start-watching-if-update-should-happen-on-quit.injectable.ts new file mode 100644 index 0000000000..ef31cf5db5 --- /dev/null +++ b/src/main/application-update/watch-if-update-should-happen-on-quit/start-watching-if-update-should-happen-on-quit.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import watchIfUpdateShouldHappenOnQuitInjectable from "./watch-if-update-should-happen-on-quit.injectable"; + +const startWatchingIfUpdateShouldHappenOnQuitInjectable = getInjectable({ + id: "start-watching-if-update-should-happen-on-quit", + + instantiate: (di) => { + const watchIfUpdateShouldHappenOnQuit = di.inject(watchIfUpdateShouldHappenOnQuitInjectable); + + return { + run: () => { + watchIfUpdateShouldHappenOnQuit.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startWatchingIfUpdateShouldHappenOnQuitInjectable; diff --git a/src/main/application-update/watch-if-update-should-happen-on-quit/stop-watching-if-update-should-happen-on-quit.injectable.ts b/src/main/application-update/watch-if-update-should-happen-on-quit/stop-watching-if-update-should-happen-on-quit.injectable.ts new file mode 100644 index 0000000000..b66cf927f2 --- /dev/null +++ b/src/main/application-update/watch-if-update-should-happen-on-quit/stop-watching-if-update-should-happen-on-quit.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import watchIfUpdateShouldHappenOnQuitInjectable from "./watch-if-update-should-happen-on-quit.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopWatchingIfUpdateShouldHappenOnQuitInjectable = getInjectable({ + id: "stop-watching-if-update-should-happen-on-quit", + + instantiate: (di) => { + const watchIfUpdateShouldHappenOnQuit = di.inject(watchIfUpdateShouldHappenOnQuitInjectable); + + return { + run: () => { + watchIfUpdateShouldHappenOnQuit.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopWatchingIfUpdateShouldHappenOnQuitInjectable; diff --git a/src/main/application-update/watch-if-update-should-happen-on-quit/watch-if-update-should-happen-on-quit.injectable.ts b/src/main/application-update/watch-if-update-should-happen-on-quit/watch-if-update-should-happen-on-quit.injectable.ts new file mode 100644 index 0000000000..12ec2d7c6e --- /dev/null +++ b/src/main/application-update/watch-if-update-should-happen-on-quit/watch-if-update-should-happen-on-quit.injectable.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { autorun } from "mobx"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import setUpdateOnQuitInjectable from "../../electron-app/features/set-update-on-quit.injectable"; +import selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import type { UpdateChannel } from "../../../common/application-update/update-channels"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; + +const watchIfUpdateShouldHappenOnQuitInjectable = getInjectable({ + id: "watch-if-update-should-happen-on-quit", + + instantiate: (di) => { + const setUpdateOnQuit = di.inject(setUpdateOnQuitInjectable); + const selectedUpdateChannel = di.inject(selectedUpdateChannelInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + + return getStartableStoppable("watch-if-update-should-happen-on-quit", () => + autorun(() => { + const sufficientlyStableUpdateChannels = + getSufficientlyStableUpdateChannels(selectedUpdateChannel.value.get()); + + const discoveredVersion = discoveredVersionState.value.get(); + + const updateIsDiscoveredFromChannel = discoveredVersion?.updateChannel; + + const updateOnQuit = updateIsDiscoveredFromChannel + ? sufficientlyStableUpdateChannels.includes( + updateIsDiscoveredFromChannel, + ) + : false; + + setUpdateOnQuit(updateOnQuit); + }), + ); + }, +}); + +const getSufficientlyStableUpdateChannels = (updateChannel: UpdateChannel): UpdateChannel[] => { + if (!updateChannel.moreStableUpdateChannel) { + return [updateChannel]; + } + + return [ + updateChannel, + + ...getSufficientlyStableUpdateChannels(updateChannel.moreStableUpdateChannel), + ]; +}; + +export default watchIfUpdateShouldHappenOnQuitInjectable; diff --git a/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap b/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap new file mode 100644 index 0000000000..8176698168 --- /dev/null +++ b/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap @@ -0,0 +1,336 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ask-boolean given started when asking multiple questions renders 1`] = ` + +

+
+
+
+ + + info_outline + + +
+
+
+ + some-title + +

+ Some question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-other-title + +

+ Some other question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking multiple questions when answering to first question renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-other-title + +

+ Some other question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking question renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-title + +

+ Some question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user answers "no" renders 1`] = ` + +
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user answers "yes" renders 1`] = ` + +
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user closes notification without answering the question renders 1`] = ` + +
+
+
+ +`; diff --git a/src/main/ask-boolean/ask-boolean-answer-channel-listener.injectable.ts b/src/main/ask-boolean/ask-boolean-answer-channel-listener.injectable.ts new file mode 100644 index 0000000000..06bc3972eb --- /dev/null +++ b/src/main/ask-boolean/ask-boolean-answer-channel-listener.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AskBooleanAnswerChannel } from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import askBooleanAnswerChannelInjectable from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import askBooleanPromiseInjectable from "./ask-boolean-promise.injectable"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; + + +const askBooleanAnswerChannelListenerInjectable = getInjectable({ + id: "ask-boolean-answer-channel-listener", + + instantiate: (di): MessageChannelListener => ({ + channel: di.inject(askBooleanAnswerChannelInjectable), + + handler: ({ id, value }) => { + const answerPromise = di.inject(askBooleanPromiseInjectable, id); + + answerPromise.resolve(value); + }, + }), + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default askBooleanAnswerChannelListenerInjectable; diff --git a/src/main/ask-boolean/ask-boolean-promise.injectable.ts b/src/main/ask-boolean/ask-boolean-promise.injectable.ts new file mode 100644 index 0000000000..827714084f --- /dev/null +++ b/src/main/ask-boolean/ask-boolean-promise.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; + +const askBooleanPromiseInjectable = getInjectable({ + id: "ask-boolean-promise", + + instantiate: (di, questionId: string) => { + void questionId; + + let resolve: (value: boolean) => void; + + const promise = new Promise(_resolve => { + resolve = _resolve; + }); + + return ({ + promise, + + resolve: (value: boolean) => { + resolve(value); + }, + }); + }, + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, questionId: string) => questionId, + }), +}); + +export default askBooleanPromiseInjectable; diff --git a/src/main/ask-boolean/ask-boolean.injectable.ts b/src/main/ask-boolean/ask-boolean.injectable.ts new file mode 100644 index 0000000000..1fa54629b2 --- /dev/null +++ b/src/main/ask-boolean/ask-boolean.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { messageToChannelInjectionToken } from "../../common/utils/channel/message-to-channel-injection-token"; +import askBooleanQuestionChannelInjectable from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import askBooleanPromiseInjectable from "./ask-boolean-promise.injectable"; +import getRandomIdInjectable from "../../common/utils/get-random-id.injectable"; + +export type AskBoolean = ({ + title, + question, +}: { + title: string; + question: string; +}) => Promise; + +const askBooleanInjectable = getInjectable({ + id: "ask-boolean", + + instantiate: (di): AskBoolean => { + const messageToChannel = di.inject(messageToChannelInjectionToken); + const askBooleanChannel = di.inject(askBooleanQuestionChannelInjectable); + const getRandomId = di.inject(getRandomIdInjectable); + + return async ({ title, question }) => { + const id = getRandomId(); + + const returnValuePromise = di.inject(askBooleanPromiseInjectable, id); + + await messageToChannel(askBooleanChannel, { id, title, question }); + + return await returnValuePromise.promise; + }; + }, +}); + +export default askBooleanInjectable; diff --git a/src/main/ask-boolean/ask-boolean.test.ts b/src/main/ask-boolean/ask-boolean.test.ts new file mode 100644 index 0000000000..79fecdceca --- /dev/null +++ b/src/main/ask-boolean/ask-boolean.test.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { AskBoolean } from "./ask-boolean.injectable"; +import askBooleanInjectable from "./ask-boolean.injectable"; +import { getPromiseStatus } from "../../common/test-utils/get-promise-status"; +import type { RenderResult } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import getRandomIdInjectable from "../../common/utils/get-random-id.injectable"; + +describe("ask-boolean", () => { + let applicationBuilder: ApplicationBuilder; + let askBoolean: AskBoolean; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + const getRandomIdFake = jest + .fn() + .mockReturnValueOnce("some-random-id-1") + .mockReturnValueOnce("some-random-id-2"); + + applicationBuilder.dis.mainDi.override(getRandomIdInjectable, () => getRandomIdFake); + + askBoolean = applicationBuilder.dis.mainDi.inject(askBooleanInjectable); + }); + + describe("given started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + describe("when asking question", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = askBoolean({ + title: "some-title", + question: "Some question", + }); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows notification", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-1"); + + expect(notification).not.toBeUndefined(); + }); + + describe('when user answers "yes"', () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-random-id-1-button-yes"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(true); + }); + }); + + describe('when user answers "no"', () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-random-id-1-button-no"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(false); + }); + }); + + describe("when user closes notification without answering the question", () => { + beforeEach(() => { + const button = rendered.getByTestId("close-notification-for-ask-boolean-for-some-random-id-1"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(false); + }); + }); + }); + + describe("when asking multiple questions", () => { + let firstQuestionPromise: Promise; + let secondQuestionPromise: Promise; + + beforeEach(() => { + firstQuestionPromise = askBoolean({ + title: "some-title", + question: "Some question", + }); + + secondQuestionPromise = askBoolean({ + title: "some-other-title", + question: "Some other question", + }); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows notification for first question", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-1"); + + expect(notification).not.toBeUndefined(); + }); + + it("shows notification for second question", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-2"); + + expect(notification).not.toBeUndefined(); + }); + + describe("when answering to first question", () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-random-id-1-button-no"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification for first question anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("still shows notification for second question", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-2"); + + expect(notification).not.toBeUndefined(); + }); + + it("resolves first question", async () => { + const actual = await firstQuestionPromise; + + expect(actual).toBe(false); + }); + + it("does not resolve second question yet", async () => { + const promiseStatus = await getPromiseStatus(secondQuestionPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + }); + }); + }); +}); diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 782c6e5950..5946a113a0 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -7,19 +7,22 @@ import { ObservableMap } from "mobx"; import type { CatalogEntity } from "../../../common/catalog"; import { loadFromOptions } from "../../../common/kube-helpers"; import type { Cluster } from "../../../common/cluster/cluster"; -import { computeDiff as computeDiffFor, configToModels } from "../kubeconfig-sync-manager/kubeconfig-sync-manager"; +import { computeDiff as computeDiffFor, configToModels } from "../kubeconfig-sync/manager"; import mockFs from "mock-fs"; import fs from "fs"; -import { ClusterManager } from "../../cluster-manager"; import clusterStoreInjectable from "../../../common/cluster-store/cluster-store.injectable"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token"; import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import getConfigurationFileModelInjectable - from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable - from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import clusterManagerInjectable from "../../cluster-manager.injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForTempInjectable from "../../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import kubectlBinaryNameInjectable from "../../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../kubectl/normalized-arch.injectable"; +import normalizedPlatformInjectable from "../../../common/vars/normalized-platform.injectable"; jest.mock("electron", () => ({ app: { @@ -45,28 +48,30 @@ describe("kubeconfig-sync.source tests", () => { mockFs(); - await di.runSetups(); - - computeDiff = computeDiffFor({ - directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), - createCluster: di.inject(createClusterInjectionToken), - }); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(directoryForTempInjectable, () => "some-directory-for-temp"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); di.override(clusterStoreInjectable, () => - ClusterStore.createInstance({ createCluster: () => null }), + ClusterStore.createInstance({ createCluster: () => null as never }), ); di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); - di.inject(clusterStoreInjectable); + computeDiff = computeDiffFor({ + directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), + createCluster: di.inject(createClusterInjectionToken), + clusterManager: di.inject(clusterManagerInjectable), + }); - ClusterManager.createInstance(); + di.inject(clusterStoreInjectable); }); afterEach(() => { mockFs.restore(); - ClusterManager.resetInstance(); ClusterStore.resetInstance(); }); diff --git a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/manager.injectable.ts similarity index 70% rename from src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts rename to src/main/catalog-sources/kubeconfig-sync/manager.injectable.ts index 7c258cabd7..f95fa0fb17 100644 --- a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts +++ b/src/main/catalog-sources/kubeconfig-sync/manager.injectable.ts @@ -4,8 +4,10 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; -import { KubeconfigSyncManager } from "./kubeconfig-sync-manager"; +import { KubeconfigSyncManager } from "./manager"; import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token"; +import clusterManagerInjectable from "../../cluster-manager.injectable"; +import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable"; const kubeconfigSyncManagerInjectable = getInjectable({ id: "kubeconfig-sync-manager", @@ -13,6 +15,8 @@ const kubeconfigSyncManagerInjectable = getInjectable({ instantiate: (di) => new KubeconfigSyncManager({ directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), createCluster: di.inject(createClusterInjectionToken), + clusterManager: di.inject(clusterManagerInjectable), + entityRegistry: di.inject(catalogEntityRegistryInjectable), }), }); diff --git a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts b/src/main/catalog-sources/kubeconfig-sync/manager.ts similarity index 88% rename from src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts rename to src/main/catalog-sources/kubeconfig-sync/manager.ts index a9e1038f53..af5510c3bc 100644 --- a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts +++ b/src/main/catalog-sources/kubeconfig-sync/manager.ts @@ -6,9 +6,9 @@ import type { IComputedValue, ObservableMap } from "mobx"; import { action, observable, computed, runInAction, makeObservable, observe } from "mobx"; import type { CatalogEntity } from "../../../common/catalog"; -import { catalogEntityRegistry } from "../../catalog"; import type { FSWatcher } from "chokidar"; import { watch } from "chokidar"; +import type { Stats } from "fs"; import fs from "fs"; import path from "path"; import type stream from "stream"; @@ -17,7 +17,8 @@ import { bytesToUnits, getOrInsertWith, iter, noop } from "../../../common/utils import logger from "../../logger"; import type { KubeConfig } from "@kubernetes/client-node"; import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; -import { catalogEntityFromCluster, ClusterManager } from "../../cluster-manager"; +import type { ClusterManager } from "../../cluster-manager"; +import { catalogEntityFromCluster } from "../../cluster-manager"; import { UserStore } from "../../../common/user-store"; import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import { createHash } from "crypto"; @@ -26,6 +27,7 @@ import globToRegExp from "glob-to-regexp"; import { inspect } from "util"; import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types"; import type { Cluster } from "../../../common/cluster/cluster"; +import type { CatalogEntityRegistry } from "../../catalog/entity-registry"; const logPrefix = "[KUBECONFIG-SYNC]:"; @@ -50,19 +52,21 @@ const ignoreGlobs = [ const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB -interface Dependencies { - directoryForKubeConfigs: string; +interface KubeconfigSyncManagerDependencies { + readonly directoryForKubeConfigs: string; + readonly entityRegistry: CatalogEntityRegistry; + readonly clusterManager: ClusterManager; createCluster: (model: ClusterModel) => Cluster; } const kubeConfigSyncName = "lens:kube-sync"; export class KubeconfigSyncManager { - protected sources = observable.map, Disposer]>(); + protected readonly sources = observable.map, Disposer]>(); protected syncing = false; protected syncListDisposer?: Disposer; - constructor(private dependencies: Dependencies) { + constructor(protected readonly dependencies: KubeconfigSyncManagerDependencies) { makeObservable(this); } @@ -76,7 +80,7 @@ export class KubeconfigSyncManager { logger.info(`${logPrefix} starting requested syncs`); - catalogEntityRegistry.addComputedSource(kubeConfigSyncName, computed(() => ( + this.dependencies.entityRegistry.addComputedSource(kubeConfigSyncName, computed(() => ( Array.from(iter.flatMap( this.sources.values(), ([entities]) => entities.get(), @@ -110,7 +114,7 @@ export class KubeconfigSyncManager { this.stopOldSync(filePath); } - catalogEntityRegistry.removeSource(kubeConfigSyncName); + this.dependencies.entityRegistry.removeSource(kubeConfigSyncName); this.syncing = false; } @@ -163,8 +167,14 @@ export function configToModels(rootConfig: KubeConfig, filePath: string): Update type RootSourceValue = [Cluster, CatalogEntity]; type RootSource = ObservableMap; +interface ComputeDiffDependencies { + directoryForKubeConfigs: string; + createCluster: (model: ClusterModel) => Cluster; + clusterManager: ClusterManager; +} + // exported for testing -export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependencies) => (contents: string, source: RootSource, filePath: string): void => { +export const computeDiff = ({ directoryForKubeConfigs, createCluster, clusterManager }: ComputeDiffDependencies) => (contents: string, source: RootSource, filePath: string): void => { runInAction(() => { try { const { config, error } = loadConfigFromString(contents); @@ -184,7 +194,7 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependen // remove and disconnect clusters that were removed from the config if (!model) { // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting - ClusterManager.getInstance().deleting.delete(value[0].id); + clusterManager.deleting.delete(value[0].id); value[0].disconnect(); source.delete(contextName); @@ -226,7 +236,6 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependen } } } catch (error) { - console.log(error); logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath }); source.clear(); // clear source if we have failed so as to not show outdated information } @@ -240,7 +249,7 @@ interface DiffChangedConfigArgs { maxAllowedFileReadSize: number; } -const diffChangedConfigFor = (dependencies: Dependencies) => ({ filePath, source, stats, maxAllowedFileReadSize }: DiffChangedConfigArgs): Disposer => { +const diffChangedConfigFor = (dependencies: ComputeDiffDependencies) => ({ filePath, source, stats, maxAllowedFileReadSize }: DiffChangedConfigArgs): Disposer => { logger.debug(`${logPrefix} file changed`, { filePath }); if (stats.size >= maxAllowedFileReadSize) { @@ -296,7 +305,7 @@ const diffChangedConfigFor = (dependencies: Dependencies) => ({ filePath, source return cleanup; }; -const watchFileChanges = (filePath: string, dependencies: Dependencies): [IComputedValue, Disposer] => { +const watchFileChanges = (filePath: string, dependencies: ComputeDiffDependencies): [IComputedValue, Disposer] => { const rootSource = observable.map>(); const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); @@ -322,12 +331,13 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu stabilityThreshold: 1000, }, atomic: 150, // for "atomic writes" + alwaysStat: true, }); const diffChangedConfig = diffChangedConfigFor(dependencies); watcher - .on("change", (childFilePath, stats) => { + .on("change", (childFilePath, stats: Stats): void => { const cleanup = cleanupFns.get(childFilePath); if (!cleanup) { @@ -343,7 +353,7 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu maxAllowedFileReadSize, })); }) - .on("add", (childFilePath, stats) => { + .on("add", (childFilePath, stats: Stats): void => { if (isFolderSync) { const fileName = path.basename(childFilePath); @@ -368,7 +378,7 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu }) .on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); } catch (error) { - console.log(error.stack); + console.log((error as { stack: unknown }).stack); logger.warn(`${logPrefix} failed to start watching changes: ${error}`); } })(); diff --git a/src/main/catalog-sources/sync-general-catalog-entities.injectable.ts b/src/main/catalog-sources/sync-general-catalog-entities.injectable.ts index 167e74c719..aaadef36cf 100644 --- a/src/main/catalog-sources/sync-general-catalog-entities.injectable.ts +++ b/src/main/catalog-sources/sync-general-catalog-entities.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import catalogEntityRegistryInjectable from "../catalog/catalog-entity-registry.injectable"; +import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; import { generalCatalogEntityInjectionToken } from "../../common/catalog-entities/general-catalog-entities/general-catalog-entity-injection-token"; import { computed } from "mobx"; diff --git a/src/main/catalog-sources/sync-weblinks.injectable.ts b/src/main/catalog-sources/sync-weblinks.injectable.ts new file mode 100644 index 0000000000..836418781d --- /dev/null +++ b/src/main/catalog-sources/sync-weblinks.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { syncWeblinks } from "./weblinks"; +import weblinkStoreInjectable from "../../common/weblink-store.injectable"; +import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; + +const syncWeblinksInjectable = getInjectable({ + id: "sync-weblinks", + + instantiate: (di) => syncWeblinks({ + weblinkStore: di.inject(weblinkStoreInjectable), + catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), + }), +}); + +export default syncWeblinksInjectable; diff --git a/src/main/catalog-sources/weblinks.ts b/src/main/catalog-sources/weblinks.ts index 71979332db..353f5f91bb 100644 --- a/src/main/catalog-sources/weblinks.ts +++ b/src/main/catalog-sources/weblinks.ts @@ -4,9 +4,9 @@ */ import { computed, observable, reaction } from "mobx"; -import { WeblinkStore } from "../../common/weblink-store"; +import type { WeblinkStore } from "../../common/weblink-store"; import { WebLink } from "../../common/catalog-entities"; -import { catalogEntityRegistry } from "../catalog"; +import type { CatalogEntityRegistry } from "../catalog"; import got from "got"; import type { Disposer } from "../../common/utils"; import { random } from "lodash"; @@ -28,9 +28,12 @@ async function validateLink(link: WebLink) { } } +interface Dependencies { + weblinkStore: WeblinkStore; + catalogEntityRegistry: CatalogEntityRegistry; +} -export function syncWeblinks() { - const weblinkStore = WeblinkStore.getInstance(); +export const syncWeblinks = ({ weblinkStore, catalogEntityRegistry }: Dependencies) => () => { const webLinkEntities = observable.map(); function periodicallyCheckLink(link: WebLink): Disposer { @@ -87,4 +90,4 @@ export function syncWeblinks() { }, { fireImmediately: true }); catalogEntityRegistry.addComputedSource("weblinks", computed(() => Array.from(webLinkEntities.values(), ([link]) => link))); -} +}; diff --git a/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts b/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts new file mode 100644 index 0000000000..7e588481a8 --- /dev/null +++ b/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { startCatalogSyncToRenderer } from "../catalog-pusher"; +import { getStartableStoppable } from "../../common/utils/get-startable-stoppable"; +import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; + +const catalogSyncToRendererInjectable = getInjectable({ + id: "catalog-sync-to-renderer", + + instantiate: (di) => { + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + + return getStartableStoppable("catalog-sync", () => + startCatalogSyncToRenderer(catalogEntityRegistry), + ); + }, + + causesSideEffects: true, +}); + +export default catalogSyncToRendererInjectable; diff --git a/src/main/catalog-sync-to-renderer/start-catalog-sync.injectable.ts b/src/main/catalog-sync-to-renderer/start-catalog-sync.injectable.ts new file mode 100644 index 0000000000..71afc4d895 --- /dev/null +++ b/src/main/catalog-sync-to-renderer/start-catalog-sync.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { afterRootFrameIsReadyInjectionToken } from "../start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token"; +import catalogSyncToRendererInjectable from "./catalog-sync-to-renderer.injectable"; + +const startCatalogSyncInjectable = getInjectable({ + id: "start-catalog-sync", + + instantiate: (di) => { + const catalogSyncToRenderer = di.inject(catalogSyncToRendererInjectable); + + return { + run: async () => { + await catalogSyncToRenderer.start(); + }, + }; + }, + + injectionToken: afterRootFrameIsReadyInjectionToken, +}); + +export default startCatalogSyncInjectable; diff --git a/src/main/catalog-sync-to-renderer/stop-catalog-sync.injectable.ts b/src/main/catalog-sync-to-renderer/stop-catalog-sync.injectable.ts new file mode 100644 index 0000000000..71c5be55f1 --- /dev/null +++ b/src/main/catalog-sync-to-renderer/stop-catalog-sync.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import catalogSyncToRendererInjectable from "./catalog-sync-to-renderer.injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token"; + +const stopCatalogSyncInjectable = getInjectable({ + id: "stop-catalog-sync", + + instantiate: (di) => { + const catalogSyncToRenderer = di.inject(catalogSyncToRendererInjectable); + + return { + run: async () => { + if (catalogSyncToRenderer.started) { + await catalogSyncToRenderer.stop(); + } + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default stopCatalogSyncInjectable; diff --git a/src/main/catalog/__tests__/catalog-entity-registry.test.ts b/src/main/catalog/__tests__/catalog-entity-registry.test.ts index 049af06318..e1004ed161 100644 --- a/src/main/catalog/__tests__/catalog-entity-registry.test.ts +++ b/src/main/catalog/__tests__/catalog-entity-registry.test.ts @@ -7,8 +7,10 @@ import { observable, reaction } from "mobx"; import type { WebLinkSpec, WebLinkStatus } from "../../../common/catalog-entities"; import { WebLink } from "../../../common/catalog-entities"; import type { CatalogEntityMetadata } from "../../../common/catalog"; -import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; -import { CatalogEntityRegistry } from "../catalog-entity-registry"; +import { CatalogEntity } from "../../../common/catalog"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import catalogEntityRegistryInjectable from "../entity-registry.injectable"; +import type { CatalogEntityRegistry } from "../entity-registry"; class InvalidEntity extends CatalogEntity { public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; @@ -32,7 +34,7 @@ class InvalidEntity extends CatalogEntity { - let registry: CatalogEntityRegistry; + let entityRegistry: CatalogEntityRegistry; const entity = new WebLink({ metadata: { uid: "test", @@ -63,26 +65,28 @@ describe("CatalogEntityRegistry", () => { }); beforeEach(() => { - registry = new CatalogEntityRegistry(catalogCategoryRegistry); + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + entityRegistry = di.inject(catalogEntityRegistryInjectable); }); describe("addSource", () => { it ("allows to add an observable source", () => { - const source = observable.array([]); + const source = observable.array([]); - registry.addObservableSource("test", source); - expect(registry.items.length).toEqual(0); + entityRegistry.addObservableSource("test", source); + expect(entityRegistry.items.length).toEqual(0); source.push(entity); - expect(registry.items.length).toEqual(1); + expect(entityRegistry.items.length).toEqual(1); }); it ("added source change triggers reaction", (done) => { - const source = observable.array([]); + const source = observable.array([]); - registry.addObservableSource("test", source); - reaction(() => registry.items, () => { + entityRegistry.addObservableSource("test", source); + reaction(() => entityRegistry.items, () => { done(); }); @@ -92,31 +96,31 @@ describe("CatalogEntityRegistry", () => { describe("removeSource", () => { it ("removes source", () => { - const source = observable.array([]); + const source = observable.array([]); - registry.addObservableSource("test", source); + entityRegistry.addObservableSource("test", source); source.push(entity); - registry.removeSource("test"); + entityRegistry.removeSource("test"); - expect(registry.items.length).toEqual(0); + expect(entityRegistry.items.length).toEqual(0); }); }); describe("items", () => { it("returns added items", () => { - expect(registry.items.length).toBe(0); + expect(entityRegistry.items.length).toBe(0); const source = observable.array([entity]); - registry.addObservableSource("test", source); - expect(registry.items.length).toBe(1); + entityRegistry.addObservableSource("test", source); + expect(entityRegistry.items.length).toBe(1); }); it("does not return items without matching category", () => { const source = observable.array([invalidEntity]); - registry.addObservableSource("test", source); - expect(registry.items.length).toBe(0); + entityRegistry.addObservableSource("test", source); + expect(entityRegistry.items.length).toBe(0); }); }); }); diff --git a/src/main/catalog/catalog-entity-registry.injectable.ts b/src/main/catalog/catalog-entity-registry.injectable.ts deleted file mode 100644 index 37584bab78..0000000000 --- a/src/main/catalog/catalog-entity-registry.injectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { catalogEntityRegistry } from "./catalog-entity-registry"; - -const catalogEntityRegistryInjectable = getInjectable({ - id: "catalog-entity-registry", - instantiate: () => catalogEntityRegistry, - causesSideEffects: true, -}); - -export default catalogEntityRegistryInjectable; diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index fa633e093c..0329a56e74 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -3,50 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, computed, type IComputedValue, type IObservableArray, makeObservable, observable } from "mobx"; -import type { CatalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog"; -import { catalogCategoryRegistry } from "../../common/catalog"; -import { iter } from "../../common/utils"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; -export class CatalogEntityRegistry { - protected sources = observable.map>(); - - constructor(private categoryRegistry: CatalogCategoryRegistry) { - makeObservable(this); - } - - @action addObservableSource(id: string, source: IObservableArray) { - this.sources.set(id, computed(() => source)); - } - - @action addComputedSource(id: string, source: IComputedValue) { - this.sources.set(id, source); - } - - @action removeSource(id: string) { - this.sources.delete(id); - } - - @computed get items(): CatalogEntity[] { - return Array.from( - iter.filter( - iter.flatMap(this.sources.values(), source => source.get()), - entity => this.categoryRegistry.getCategoryForEntity(entity), - ), - ); - } - - getById(id: string): T | undefined { - return this.items.find(entity => entity.getId() === id) as T | undefined; - } - - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; - } - - getItemsByEntityClass(constructor: CatalogEntityConstructor): T[] { - return this.items.filter((item) => item instanceof constructor) as T[]; - } -} - -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); +/** + * @deprecated use `di.inject(catalogEntityRegistryInjectable)` instead + */ +export const catalogEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable); diff --git a/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts b/src/main/catalog/entity-registry.injectable.ts similarity index 54% rename from src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts rename to src/main/catalog/entity-registry.injectable.ts index 98af561838..003ed3d62e 100644 --- a/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts +++ b/src/main/catalog/entity-registry.injectable.ts @@ -3,11 +3,15 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { catalogEntityRegistry } from "../catalog-entity-registry"; +import hasCategoryForEntityInjectable from "../../common/catalog/has-category-for-entity.injectable"; +import { CatalogEntityRegistry } from "./entity-registry"; const catalogEntityRegistryInjectable = getInjectable({ id: "catalog-entity-registry", - instantiate: () => catalogEntityRegistry, + + instantiate: (di) => new CatalogEntityRegistry({ + hasCategoryForEntity: di.inject(hasCategoryForEntityInjectable), + }), }); export default catalogEntityRegistryInjectable; diff --git a/src/main/catalog/entity-registry.ts b/src/main/catalog/entity-registry.ts new file mode 100644 index 0000000000..972da73132 --- /dev/null +++ b/src/main/catalog/entity-registry.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, computed, type IComputedValue, type IObservableArray, makeObservable, observable } from "mobx"; +import type { CatalogEntity } from "../../common/catalog"; +import type { HasCategoryForEntity } from "../../common/catalog/has-category-for-entity.injectable"; +import { iter } from "../../common/utils"; + +interface Dependencies { + readonly hasCategoryForEntity: HasCategoryForEntity; +} + +export class CatalogEntityRegistry { + protected sources = observable.map>(); + + constructor(protected readonly dependencies: Dependencies) { + makeObservable(this); + } + + @action addObservableSource(id: string, source: IObservableArray) { + this.sources.set(id, computed(() => source)); + } + + @action addComputedSource(id: string, source: IComputedValue) { + this.sources.set(id, source); + } + + @action removeSource(id: string) { + this.sources.delete(id); + } + + @computed get items(): CatalogEntity[] { + return Array.from( + iter.filter( + iter.flatMap(this.sources.values(), source => source.get()), + entity => this.dependencies.hasCategoryForEntity(entity), + ), + ); + } + + findById(id: string): CatalogEntity | undefined { + return this.items.find(entity => entity.getId() === id); + } + + filterItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] { + return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); + } + + filterItemsByPredicate(filter: (item: CatalogEntity) => item is E): E[] { + return this.items.filter(filter); + } +} diff --git a/src/main/catalog/index.ts b/src/main/catalog/index.ts index 011cf9cdbc..306d26317d 100644 --- a/src/main/catalog/index.ts +++ b/src/main/catalog/index.ts @@ -4,3 +4,4 @@ */ export * from "./catalog-entity-registry"; +export * from "./entity-registry"; diff --git a/src/main/child-process/spawn.injectable.ts b/src/main/child-process/spawn.injectable.ts index d313e19d95..558aefc78e 100644 --- a/src/main/child-process/spawn.injectable.ts +++ b/src/main/child-process/spawn.injectable.ts @@ -5,12 +5,12 @@ import { getInjectable } from "@ogre-tools/injectable"; import { spawn } from "child_process"; +export type Spawn = typeof spawn; + const spawnInjectable = getInjectable({ id: "spawn", - instantiate: () => { - return spawn; - }, + instantiate: (): Spawn => spawn, causesSideEffects: true, }); diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index a5a2564ab4..1aca321dfe 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -5,24 +5,21 @@ import type { RequestPromiseOptions } from "request-promise-native"; import type { Cluster } from "../../common/cluster/cluster"; -import { k8sRequest } from "../k8s-request"; +import type { K8sRequest } from "../k8s-request.injectable"; export interface ClusterDetectionResult { value: string | number | boolean; accuracy: number; } -export class BaseClusterDetector { - key: string; +export abstract class BaseClusterDetector { + abstract readonly key: string; - constructor(public cluster: Cluster) { - } + constructor(public readonly cluster: Cluster, private _k8sRequest: K8sRequest) {} - detect(): Promise { - return null; - } + abstract detect(): Promise; protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { - return k8sRequest(this.cluster, path, options); + return this._k8sRequest(this.cluster, path, options); } } diff --git a/src/main/cluster-detectors/create-version-detector.injectable.ts b/src/main/cluster-detectors/create-version-detector.injectable.ts new file mode 100644 index 0000000000..efd59dcfc3 --- /dev/null +++ b/src/main/cluster-detectors/create-version-detector.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { VersionDetector } from "./version-detector"; +import k8sRequestInjectable from "../k8s-request.injectable"; +import type { Cluster } from "../../common/cluster/cluster"; + +const createVersionDetectorInjectable = getInjectable({ + id: "create-version-detector", + + instantiate: (di) => { + const k8sRequest = di.inject(k8sRequestInjectable); + + return (cluster: Cluster) => + new VersionDetector(cluster, k8sRequest); + }, +}); + +export default createVersionDetectorInjectable; diff --git a/src/main/cluster-detectors/detector-registry.injectable.ts b/src/main/cluster-detectors/detector-registry.injectable.ts new file mode 100644 index 0000000000..344e82003f --- /dev/null +++ b/src/main/cluster-detectors/detector-registry.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { DetectorRegistry } from "./detector-registry"; +import k8sRequestInjectable from "../k8s-request.injectable"; + +const detectorRegistryInjectable = getInjectable({ + id: "detector-registry", + + instantiate: (di) => + new DetectorRegistry({ k8sRequest: di.inject(k8sRequestInjectable) }), +}); + +export default detectorRegistryInjectable; diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index e2741c1353..395d819850 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -5,14 +5,22 @@ import { observable } from "mobx"; import type { ClusterMetadata } from "../../common/cluster-types"; -import { Singleton } from "../../common/utils"; import type { Cluster } from "../../common/cluster/cluster"; import type { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector"; +import type { K8sRequest } from "../k8s-request.injectable"; -export class DetectorRegistry extends Singleton { - registry = observable.array([], { deep: false }); +interface Dependencies { + k8sRequest: K8sRequest; +} - add(detectorClass: typeof BaseClusterDetector): this { +export type DetectorConstructor = new (cluster: Cluster, k8sRequest: K8sRequest) => BaseClusterDetector; + +export class DetectorRegistry { + constructor(private dependencies: Dependencies) {} + + registry = observable.array([], { deep: false }); + + add(detectorClass: DetectorConstructor): this { this.registry.push(detectorClass); return this; @@ -22,7 +30,7 @@ export class DetectorRegistry extends Singleton { const results: { [key: string]: ClusterDetectionResult } = {}; for (const detectorClass of this.registry) { - const detector = new detectorClass(cluster); + const detector = new detectorClass(cluster, this.dependencies.k8sRequest); try { const data = await detector.detect(); diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts index 83610fdaa0..c912fcf5b5 100644 --- a/src/main/cluster-detectors/distribution-detector.ts +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -8,12 +8,11 @@ import { ClusterMetadataKey } from "../../common/cluster-types"; export class DistributionDetector extends BaseClusterDetector { key = ClusterMetadataKey.DISTRIBUTION; - version: string; public async detect() { - this.version = await this.getKubernetesVersion(); + const version = await this.getKubernetesVersion(); - if (this.isRke()) { + if (this.isRke(version)) { return { value: "rke", accuracy: 80 }; } @@ -21,19 +20,19 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "rancher-desktop", accuracy: 80 }; } - if (this.isK3s()) { + if (this.isK3s(version)) { return { value: "k3s", accuracy: 80 }; } - if (this.isGKE()) { + if (this.isGKE(version)) { return { value: "gke", accuracy: 80 }; } - if (this.isEKS()) { + if (this.isEKS(version)) { return { value: "eks", accuracy: 80 }; } - if (this.isIKS()) { + if (this.isIKS(version)) { return { value: "iks", accuracy: 80 }; } @@ -45,27 +44,27 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "digitalocean", accuracy: 90 }; } - if (this.isK0s()) { + if (this.isK0s(version)) { return { value: "k0s", accuracy: 80 }; } - if (this.isVMWare()) { + if (this.isVMWare(version)) { return { value: "vmware", accuracy: 90 }; } - if (this.isMirantis()) { + if (this.isMirantis(version)) { return { value: "mirantis", accuracy: 90 }; } - if (this.isAlibaba()) { + if (this.isAlibaba(version)) { return { value: "alibaba", accuracy: 90 }; } - if (this.isHuawei()) { + if (this.isHuawei(version)) { return { value: "huawei", accuracy: 90 }; } - if (this.isTke()) { + if (this.isTke(version)) { return { value: "tencent", accuracy: 90 }; } @@ -85,11 +84,11 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "docker-desktop", accuracy: 80 }; } - if (this.isCustom() && await this.isOpenshift()) { + if (this.isCustom(version) && await this.isOpenshift()) { return { value: "openshift", accuracy: 90 }; } - if (this.isCustom()) { + if (this.isCustom(version)) { return { value: "custom", accuracy: 10 }; } @@ -104,24 +103,24 @@ export class DistributionDetector extends BaseClusterDetector { return response.gitVersion; } - protected isGKE() { - return this.version.includes("gke"); + protected isGKE(version: string) { + return version.includes("gke"); } - protected isEKS() { - return this.version.includes("eks"); + protected isEKS(version: string) { + return version.includes("eks"); } - protected isIKS() { - return this.version.includes("IKS"); + protected isIKS(version: string) { + return version.includes("IKS"); } protected isAKS() { return this.cluster.apiUrl.includes("azmk8s.io"); } - protected isMirantis() { - return this.version.includes("-mirantis-") || this.version.includes("-docker-"); + protected isMirantis(version: string) { + return version.includes("-mirantis-") || version.includes("-docker-"); } protected isDigitalOcean() { @@ -144,40 +143,40 @@ export class DistributionDetector extends BaseClusterDetector { return this.cluster.contextName === "docker-desktop"; } - protected isTke() { - return this.version.includes("-tke."); + protected isTke(version: string) { + return version.includes("-tke."); } - protected isCustom() { - return this.version.includes("+"); + protected isCustom(version: string) { + return version.includes("+"); } - protected isVMWare() { - return this.version.includes("+vmware"); + protected isVMWare(version: string) { + return version.includes("+vmware"); } - protected isRke() { - return this.version.includes("-rancher"); + protected isRke(version: string) { + return version.includes("-rancher"); } protected isRancherDesktop() { return this.cluster.contextName === "rancher-desktop"; } - protected isK3s() { - return this.version.includes("+k3s"); + protected isK3s(version: string) { + return version.includes("+k3s"); } - protected isK0s() { - return this.version.includes("-k0s") || this.version.includes("+k0s"); + protected isK0s(version: string) { + return version.includes("-k0s") || version.includes("+k0s"); } - protected isAlibaba() { - return this.version.includes("-aliyun"); + protected isAlibaba(version: string) { + return version.includes("-aliyun"); } - protected isHuawei() { - return this.version.includes("-CCE"); + protected isHuawei(version: string) { + return version.includes("-CCE"); } protected async isOpenshift() { diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index 50b55b433d..cc228734c5 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -8,7 +8,6 @@ import { ClusterMetadataKey } from "../../common/cluster-types"; export class VersionDetector extends BaseClusterDetector { key = ClusterMetadataKey.VERSION; - value: string; public async detect() { const version = await this.getKubernetesVersion(); diff --git a/src/main/cluster-manager.injectable.ts b/src/main/cluster-manager.injectable.ts new file mode 100644 index 0000000000..2b55f0e854 --- /dev/null +++ b/src/main/cluster-manager.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { ClusterManager } from "./cluster-manager"; +import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; +import catalogEntityRegistryInjectable from "./catalog/entity-registry.injectable"; + +const clusterManagerInjectable = getInjectable({ + id: "cluster-manager", + + instantiate: (di) => { + const clusterManager = new ClusterManager({ + store: di.inject(clusterStoreInjectable), + catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), + }); + + clusterManager.init(); + + return clusterManager; + }, +}); + +export default clusterManagerInjectable; diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 498884785d..787f2a1618 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -9,52 +9,55 @@ import { action, makeObservable, observable, observe, reaction, toJS } from "mob import type { Cluster } from "../common/cluster/cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; -import { getClusterIdFromHost, Singleton } from "../common/utils"; -import { catalogEntityRegistry } from "./catalog"; +import { getClusterIdFromHost, isErrnoException } from "../common/utils"; import type { KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; -import { KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; +import { isKubernetesCluster, KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; import { ipcMainOn } from "../common/ipc"; import { once } from "lodash"; -import { ClusterStore } from "../common/cluster-store/cluster-store"; +import type { ClusterStore } from "../common/cluster-store/cluster-store"; import type { ClusterId } from "../common/cluster-types"; +import type { CatalogEntityRegistry } from "./catalog"; const logPrefix = "[CLUSTER-MANAGER]:"; const lensSpecificClusterStatuses: Set = new Set(Object.values(LensKubernetesClusterStatus)); -export class ClusterManager extends Singleton { - private store = ClusterStore.getInstance(); +interface Dependencies { + store: ClusterStore; + catalogEntityRegistry: CatalogEntityRegistry; +} + +export class ClusterManager { deleting = observable.set(); @observable visibleCluster: ClusterId | undefined = undefined; - constructor() { - super(); + constructor(private dependencies: Dependencies) { makeObservable(this); } init = once(() => { // reacting to every cluster's state change and total amount of items reaction( - () => this.store.clustersList.map(c => c.getState()), - () => this.updateCatalog(this.store.clustersList), + () => this.dependencies.store.clustersList.map(c => c.getState()), + () => this.updateCatalog(this.dependencies.store.clustersList), { fireImmediately: false }, ); // reacting to every cluster's preferences change and total amount of items reaction( - () => this.store.clustersList.map(c => toJS(c.preferences)), - () => this.updateCatalog(this.store.clustersList), + () => this.dependencies.store.clustersList.map(c => toJS(c.preferences)), + () => this.updateCatalog(this.dependencies.store.clustersList), { fireImmediately: false }, ); reaction( - () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster) as KubernetesCluster[], + () => this.dependencies.catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster), entities => this.syncClustersFromCatalog(entities), ); reaction(() => [ - catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + this.dependencies.catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster), this.visibleCluster, ] as const, ([entities, visibleCluster]) => { for (const entity of entities) { @@ -68,7 +71,7 @@ export class ClusterManager extends Singleton { observe(this.deleting, change => { if (change.type === "add") { - this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue)); + this.updateEntityStatus(this.dependencies.catalogEntityRegistry.findById(change.newValue) as KubernetesCluster); } }); @@ -86,13 +89,13 @@ export class ClusterManager extends Singleton { } protected updateEntityFromCluster(cluster: Cluster) { - const index = catalogEntityRegistry.items.findIndex((entity) => entity.getId() === cluster.id); + const index = this.dependencies.catalogEntityRegistry.items.findIndex((entity) => entity.getId() === cluster.id); if (index === -1) { return; } - const entity = catalogEntityRegistry.items[index] as KubernetesCluster; + const entity = this.dependencies.catalogEntityRegistry.items[index] as KubernetesCluster; this.updateEntityStatus(entity, cluster); @@ -133,7 +136,7 @@ export class ClusterManager extends Singleton { cluster.preferences.icon = undefined; } - catalogEntityRegistry.items.splice(index, 1, entity); + this.dependencies.catalogEntityRegistry.items.splice(index, 1, entity); } @action @@ -180,7 +183,7 @@ export class ClusterManager extends Singleton { @action protected syncClustersFromCatalog(entities: KubernetesCluster[]) { for (const entity of entities) { - const cluster = this.store.getById(entity.getId()); + const cluster = this.dependencies.store.getById(entity.getId()); if (!cluster) { const model = { @@ -195,9 +198,9 @@ export class ClusterManager extends Singleton { * Add the bare minimum of data to ClusterStore. And especially no * preferences, as those might be configured by the entity's source */ - this.store.addCluster(model); + this.dependencies.store.addCluster(model); } catch (error) { - if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { + if (isErrnoException(error) && error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { logger.warn(`${logPrefix} kubeconfig file disappeared`, model); } else { logger.error(`${logPrefix} failed to add cluster: ${error}`, model); @@ -207,7 +210,7 @@ export class ClusterManager extends Singleton { cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; - if (entity.spec.accessibleNamespace) { + if (entity.spec.accessibleNamespaces) { cluster.accessibleNamespaces = entity.spec.accessibleNamespaces; } @@ -234,7 +237,7 @@ export class ClusterManager extends Singleton { protected onNetworkOffline = () => { logger.info(`${logPrefix} network is offline`); - this.store.clustersList.forEach((cluster) => { + this.dependencies.store.clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.online = false; cluster.accessible = false; @@ -245,7 +248,7 @@ export class ClusterManager extends Singleton { protected onNetworkOnline = () => { logger.info(`${logPrefix} network is online`); - this.store.clustersList.forEach((cluster) => { + this.dependencies.store.clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.refreshConnectionStatus().catch((e) => e); } @@ -253,16 +256,20 @@ export class ClusterManager extends Singleton { }; stop() { - this.store.clusters.forEach((cluster: Cluster) => { + this.dependencies.store.clusters.forEach((cluster: Cluster) => { cluster.disconnect(); }); } - getClusterForRequest(req: http.IncomingMessage): Cluster { + getClusterForRequest = (req: http.IncomingMessage): Cluster | undefined => { + if (!req.headers.host) { + return undefined; + } + // lens-server is connecting to 127.0.0.1:/ - if (req.headers.host.startsWith("127.0.0.1")) { + if (req.url && req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; - const cluster = this.store.getById(clusterId); + const cluster = this.dependencies.store.getById(clusterId); if (cluster) { // we need to swap path prefix so that request is proxied to kube api @@ -272,8 +279,8 @@ export class ClusterManager extends Singleton { return cluster; } - return this.store.getById(getClusterIdFromHost(req.headers.host)); - } + return this.dependencies.store.getById(getClusterIdFromHost(req.headers.host)); + }; } export function catalogEntityFromCluster(cluster: Cluster) { diff --git a/src/main/context-handler/context-handler.ts b/src/main/context-handler/context-handler.ts index 46e100c07e..cf35a0a67c 100644 --- a/src/main/context-handler/context-handler.ts +++ b/src/main/context-handler/context-handler.ts @@ -3,8 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { PrometheusProvider, PrometheusService } from "../prometheus/provider-registry"; -import { PrometheusProviderRegistry } from "../prometheus/provider-registry"; +import type { PrometheusProvider, PrometheusService, PrometheusProviderRegistry } from "../prometheus/provider-registry"; import type { ClusterPrometheusPreferences } from "../../common/cluster-types"; import type { Cluster } from "../../common/cluster/cluster"; import type httpProxy from "http-proxy"; @@ -28,12 +27,25 @@ interface PrometheusServicePreferences { } interface Dependencies { - createKubeAuthProxy: CreateKubeAuthProxy; - authProxyCa: string; + readonly createKubeAuthProxy: CreateKubeAuthProxy; + readonly authProxyCa: string; + readonly prometheusProviderRegistry: PrometheusProviderRegistry; } -export class ContextHandler { - public clusterUrl: UrlWithStringQuery; +export interface ClusterContextHandler { + readonly clusterUrl: UrlWithStringQuery; + setupPrometheus(preferences?: ClusterPrometheusPreferences): void; + getPrometheusDetails(): Promise; + resolveAuthProxyUrl(): Promise; + resolveAuthProxyCa(): string; + getApiTarget(isLongRunningRequest?: boolean): Promise; + restartServer(): Promise; + ensureServer(): Promise; + stopServer(): void; +} + +export class ContextHandler implements ClusterContextHandler { + public readonly clusterUrl: UrlWithStringQuery; protected kubeAuthProxy?: KubeAuthProxy; protected apiTarget?: httpProxy.ServerOptions; protected prometheusProvider?: string; @@ -46,7 +58,7 @@ export class ContextHandler { public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { this.prometheusProvider = preferences.prometheusProvider?.type; - this.prometheus = preferences.prometheus || null; + this.prometheus = preferences.prometheus; } public async getPrometheusDetails(): Promise { @@ -67,11 +79,11 @@ export class ContextHandler { this.prometheusProvider = service.id; } - return PrometheusProviderRegistry.getInstance().getByKind(this.prometheusProvider); + return this.dependencies.prometheusProviderRegistry.getByKind(this.prometheusProvider); } protected listPotentialProviders(): PrometheusProvider[] { - const registry = PrometheusProviderRegistry.getInstance(); + const registry = this.dependencies.prometheusProviderRegistry; const provider = this.prometheusProvider && registry.getByKind(this.prometheusProvider); if (provider) { @@ -82,7 +94,7 @@ export class ContextHandler { } protected async getPrometheusService(): Promise { - if (this.prometheus !== null && this.prometheusProvider !== null) { + if (this.prometheus && this.prometheusProvider) { return { id: this.prometheusProvider, namespace: this.prometheus.namespace, @@ -117,11 +129,11 @@ export class ContextHandler { throw Object.assign(new Error("No Prometheus service found"), { cause: errors }); } - async resolveAuthProxyUrl() { - await this.ensureServer(); + async resolveAuthProxyUrl(): Promise { + const kubeAuthProxy = await this.ensureServerHelper(); const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - return `https://127.0.0.1:${this.kubeAuthProxy.port}${this.kubeAuthProxy.apiPrefix}${path}`; + return `https://127.0.0.1:${kubeAuthProxy.port}${kubeAuthProxy.apiPrefix}${path}`; } resolveAuthProxyCa() { @@ -139,30 +151,32 @@ export class ContextHandler { } protected async newApiTarget(timeout: number): Promise { - await this.ensureServer(); - - const ca = this.dependencies.authProxyCa; + const kubeAuthProxy = await this.ensureServerHelper(); + const ca = this.resolveAuthProxyCa(); const clusterPath = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - const apiPrefix = `${this.kubeAuthProxy.apiPrefix}${clusterPath}`; + const apiPrefix = `${kubeAuthProxy.apiPrefix}${clusterPath}`; + const headers: Record = {}; + + if (this.clusterUrl.hostname) { + headers.Host = this.clusterUrl.hostname; + } return { target: { protocol: "https:", host: "127.0.0.1", - port: this.kubeAuthProxy.port, + port: kubeAuthProxy.port, path: apiPrefix, ca, }, changeOrigin: true, timeout, secure: true, - headers: { - "Host": this.clusterUrl.hostname, - }, + headers, }; } - async ensureServer() { + protected async ensureServerHelper(): Promise { if (!this.kubeAuthProxy) { const proxyEnv = Object.assign({}, process.env); @@ -171,9 +185,23 @@ export class ContextHandler { } this.kubeAuthProxy = this.dependencies.createKubeAuthProxy(this.cluster, proxyEnv); await this.kubeAuthProxy.run(); + + return this.kubeAuthProxy; } await this.kubeAuthProxy.whenReady; + + return this.kubeAuthProxy; + } + + async ensureServer(): Promise { + await this.ensureServerHelper(); + } + + async restartServer(): Promise { + this.stopServer(); + + await this.ensureServerHelper(); } stopServer() { diff --git a/src/main/context-handler/create-context-handler.injectable.ts b/src/main/context-handler/create-context-handler.injectable.ts index 86867ccdfb..8185bd7c80 100644 --- a/src/main/context-handler/create-context-handler.injectable.ts +++ b/src/main/context-handler/create-context-handler.injectable.ts @@ -5,20 +5,26 @@ import { getInjectable } from "@ogre-tools/injectable"; import selfsigned from "selfsigned"; import type { Cluster } from "../../common/cluster/cluster"; +import type { ClusterContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; import { getKubeAuthProxyCertificate } from "../kube-auth-proxy/get-kube-auth-proxy-certificate"; import URLParse from "url-parse"; +import prometheusProviderRegistryInjectable from "../prometheus/prometheus-provider-registry.injectable"; const createContextHandlerInjectable = getInjectable({ id: "create-context-handler", instantiate: (di) => { - return (cluster: Cluster) => { + const createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); + const prometheusProviderRegistry = di.inject(prometheusProviderRegistryInjectable); + + return (cluster: Cluster): ClusterContextHandler => { const clusterUrl = new URLParse(cluster.apiUrl); const dependencies = { - createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable), + createKubeAuthProxy, + prometheusProviderRegistry, authProxyCa: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate).cert, }; diff --git a/src/main/create-cluster/create-cluster.injectable.ts b/src/main/create-cluster/create-cluster.injectable.ts index d62c37e9c7..cd34979925 100644 --- a/src/main/create-cluster/create-cluster.injectable.ts +++ b/src/main/create-cluster/create-cluster.injectable.ts @@ -12,6 +12,9 @@ import createContextHandlerInjectable from "../context-handler/create-context-ha import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import detectorRegistryInjectable from "../cluster-detectors/detector-registry.injectable"; +import createVersionDetectorInjectable from "../cluster-detectors/create-version-detector.injectable"; const createClusterInjectable = getInjectable({ id: "create-cluster", @@ -24,6 +27,9 @@ const createClusterInjectable = getInjectable({ createContextHandler: di.inject(createContextHandlerInjectable), createAuthorizationReview: di.inject(authorizationReviewInjectable), createListNamespaces: di.inject(listNamespacesInjectable), + logger: di.inject(loggerInjectable), + detectorRegistry: di.inject(detectorRegistryInjectable), + createVersionDetector: di.inject(createVersionDetectorInjectable), }; return (model) => new Cluster(dependencies, model); diff --git a/src/main/developer-tools.ts b/src/main/developer-tools.ts deleted file mode 100644 index f3e40263f0..0000000000 --- a/src/main/developer-tools.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import logger from "./logger"; - -/** - * Installs Electron developer tools in the development build. - * The dependency is not bundled to the production build. - */ -export const installDeveloperTools = () => { - if (process.env.NODE_ENV === "development") { - logger.info("🤓 Installing developer tools"); - import("electron-devtools-installer") - .then(({ default: devToolsInstaller, REACT_DEVELOPER_TOOLS }) => devToolsInstaller([REACT_DEVELOPER_TOOLS])) - .then((name) => logger.info(`[DEVTOOLS-INSTALLER]: installed ${name}`)) - .catch(error => logger.error(`[DEVTOOLS-INSTALLER]: failed`, { error })); - } -}; diff --git a/src/main/app-paths/get-electron-app-path/electron-app/electron-app.injectable.ts b/src/main/electron-app/electron-app.injectable.ts similarity index 100% rename from src/main/app-paths/get-electron-app-path/electron-app/electron-app.injectable.ts rename to src/main/electron-app/electron-app.injectable.ts diff --git a/src/main/electron-app/features/auto-updater.injectable.ts b/src/main/electron-app/features/auto-updater.injectable.ts new file mode 100644 index 0000000000..24bb560e1f --- /dev/null +++ b/src/main/electron-app/features/auto-updater.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { autoUpdater } from "electron"; + +const autoUpdaterInjectable = getInjectable({ + id: "auto-updater", + instantiate: () => autoUpdater, + causesSideEffects: true, +}); + +export default autoUpdaterInjectable; diff --git a/src/main/electron-app/features/disable-hardware-acceleration.injectable.ts b/src/main/electron-app/features/disable-hardware-acceleration.injectable.ts new file mode 100644 index 0000000000..1c1e291644 --- /dev/null +++ b/src/main/electron-app/features/disable-hardware-acceleration.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; + +const disableHardwareAccelerationInjectable = getInjectable({ + id: "disable-hardware-acceleration", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + + return () => { + app.disableHardwareAcceleration(); + }; + }, +}); + +export default disableHardwareAccelerationInjectable; diff --git a/src/main/electron-app/features/electron-dialog.injectable.ts b/src/main/electron-app/features/electron-dialog.injectable.ts new file mode 100644 index 0000000000..95de13f426 --- /dev/null +++ b/src/main/electron-app/features/electron-dialog.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { dialog } from "electron"; + +const electronDialogInjectable = getInjectable({ + id: "electron-dialog", + instantiate: () => dialog, + causesSideEffects: true, +}); + +export default electronDialogInjectable; diff --git a/src/main/electron-app/features/electron-updater-is-active.injectable.ts b/src/main/electron-app/features/electron-updater-is-active.injectable.ts new file mode 100644 index 0000000000..2fe0d7bf06 --- /dev/null +++ b/src/main/electron-app/features/electron-updater-is-active.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronUpdaterInjectable from "./electron-updater.injectable"; + +const electronUpdaterIsActiveInjectable = getInjectable({ + id: "electron-updater-is-active", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + + return electronUpdater.isUpdaterActive(); + }, +}); + +export default electronUpdaterIsActiveInjectable; diff --git a/src/main/electron-app/features/electron-updater.injectable.ts b/src/main/electron-app/features/electron-updater.injectable.ts new file mode 100644 index 0000000000..f9e3335343 --- /dev/null +++ b/src/main/electron-app/features/electron-updater.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { autoUpdater } from "electron-updater"; + +const electronUpdaterInjectable = getInjectable({ + id: "electron-updater", + instantiate: () => autoUpdater, + causesSideEffects: true, +}); + +export default electronUpdaterInjectable; diff --git a/src/main/electron-app/features/exit-app.injectable.ts b/src/main/electron-app/features/exit-app.injectable.ts new file mode 100644 index 0000000000..0734b4c8c5 --- /dev/null +++ b/src/main/electron-app/features/exit-app.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; + +const exitAppInjectable = getInjectable({ + id: "exit-app", + + instantiate: (di) => () => { + const app = di.inject(electronAppInjectable); + + app.exit(0); + }, +}); + +export default exitAppInjectable; diff --git a/src/main/electron-app/features/get-command-line-switch.injectable.ts b/src/main/electron-app/features/get-command-line-switch.injectable.ts new file mode 100644 index 0000000000..271e2f367d --- /dev/null +++ b/src/main/electron-app/features/get-command-line-switch.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; + +const getCommandLineSwitchInjectable = getInjectable({ + id: "get-command-line-switch", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + + return (name: string) => app.commandLine.getSwitchValue(name); + }, +}); + +export default getCommandLineSwitchInjectable; diff --git a/src/main/electron-app/features/get-electron-theme.injectable.ts b/src/main/electron-app/features/get-electron-theme.injectable.ts new file mode 100644 index 0000000000..220c047aa6 --- /dev/null +++ b/src/main/electron-app/features/get-electron-theme.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import nativeThemeInjectable from "./native-theme.injectable"; + +const getElectronThemeInjectable = getInjectable({ + id: "get-electron-theme", + + instantiate: (di) => { + const nativeTheme = di.inject(nativeThemeInjectable); + + return () => nativeTheme.shouldUseDarkColors ? "dark" : "light"; + }, +}); + +export default getElectronThemeInjectable; diff --git a/src/main/electron-app/features/native-theme.injectable.ts b/src/main/electron-app/features/native-theme.injectable.ts new file mode 100644 index 0000000000..a2d29d8835 --- /dev/null +++ b/src/main/electron-app/features/native-theme.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { nativeTheme } from "electron"; + +const nativeThemeInjectable = getInjectable({ + id: "native-theme", + instantiate: () => nativeTheme, + causesSideEffects: true, +}); + +export default nativeThemeInjectable; diff --git a/src/main/electron-app/features/power-monitor.injectable.ts b/src/main/electron-app/features/power-monitor.injectable.ts new file mode 100644 index 0000000000..6ef08938fd --- /dev/null +++ b/src/main/electron-app/features/power-monitor.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { powerMonitor } from "electron"; + +const powerMonitorInjectable = getInjectable({ + id: "power-monitor", + instantiate: () => powerMonitor, + causesSideEffects: true, +}); + +export default powerMonitorInjectable; diff --git a/src/main/electron-app/features/quit-and-install-update.injectable.ts b/src/main/electron-app/features/quit-and-install-update.injectable.ts new file mode 100644 index 0000000000..6b313e21b0 --- /dev/null +++ b/src/main/electron-app/features/quit-and-install-update.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronUpdaterInjectable from "./electron-updater.injectable"; + +const quitAndInstallUpdateInjectable = getInjectable({ + id: "quit-and-install-update", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + + return () => { + electronUpdater.quitAndInstall(true, true); + }; + }, +}); + +export default quitAndInstallUpdateInjectable; diff --git a/src/main/electron-app/features/request-single-instance-lock.injectable.ts b/src/main/electron-app/features/request-single-instance-lock.injectable.ts new file mode 100644 index 0000000000..eef6d9eaeb --- /dev/null +++ b/src/main/electron-app/features/request-single-instance-lock.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; + +const requestSingleInstanceLockInjectable = getInjectable({ + id: "request-single-instance-lock", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + + return () => app.requestSingleInstanceLock(); + }, +}); + +export default requestSingleInstanceLockInjectable; diff --git a/src/main/electron-app/features/set-update-on-quit.injectable.ts b/src/main/electron-app/features/set-update-on-quit.injectable.ts new file mode 100644 index 0000000000..43693f8eed --- /dev/null +++ b/src/main/electron-app/features/set-update-on-quit.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronUpdaterInjectable from "./electron-updater.injectable"; + +const setUpdateOnQuitInjectable = getInjectable({ + id: "set-update-on-quit", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + + return (updateOnQuit: boolean) => { + electronUpdater.autoInstallOnAppQuit = updateOnQuit; + }; + }, +}); + +export default setUpdateOnQuitInjectable; diff --git a/src/main/electron-app/features/should-start-hidden.injectable.ts b/src/main/electron-app/features/should-start-hidden.injectable.ts new file mode 100644 index 0000000000..092282ac68 --- /dev/null +++ b/src/main/electron-app/features/should-start-hidden.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; +import isMacInjectable from "../../../common/vars/is-mac.injectable"; +import commandLineArgumentsInjectable from "../../utils/command-line-arguments.injectable"; + +const shouldStartHiddenInjectable = getInjectable({ + id: "should-start-hidden", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + const isMac = di.inject(isMacInjectable); + const commandLineArguments = di.inject(commandLineArgumentsInjectable); + + // Start the app without showing the main window when auto starting on login + // (On Windows and Linux, we get a flag. On MacOS, we get special API.) + return ( + commandLineArguments.includes("--hidden") || + (isMac && app.getLoginItemSettings().wasOpenedAsHidden) + ); + }, +}); + +export default shouldStartHiddenInjectable; diff --git a/src/main/electron-app/features/show-error-popup.injectable.ts b/src/main/electron-app/features/show-error-popup.injectable.ts new file mode 100644 index 0000000000..fbf31a7137 --- /dev/null +++ b/src/main/electron-app/features/show-error-popup.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronDialogInjectable from "./electron-dialog.injectable"; + +const showErrorPopupInjectable = getInjectable({ + id: "show-error-popup", + + instantiate: (di) => { + const dialog = di.inject(electronDialogInjectable); + + return (heading: string, message: string) => { + dialog.showErrorBox(heading, message); + }; + }, +}); + +export default showErrorPopupInjectable; diff --git a/src/main/electron-app/features/show-message-popup.injectable.ts b/src/main/electron-app/features/show-message-popup.injectable.ts new file mode 100644 index 0000000000..7ad2f53c6f --- /dev/null +++ b/src/main/electron-app/features/show-message-popup.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronDialogInjectable from "./electron-dialog.injectable"; + +export type ShowMessagePopup = (title: string, message: string, detail: string) => void; + +const showMessagePopupInjectable = getInjectable({ + id: "show-message-popup", + + instantiate: (di): ShowMessagePopup => { + const dialog = di.inject(electronDialogInjectable); + + return async (title, message, detail) => { + await dialog.showMessageBox({ + title, + message, + detail, + type: "info", + buttons: ["Close"], + }); + }; + }, +}); + +export default showMessagePopupInjectable; diff --git a/src/main/electron-app/features/sync-theme-from-operating-system.injectable.ts b/src/main/electron-app/features/sync-theme-from-operating-system.injectable.ts new file mode 100644 index 0000000000..fa2f8e335e --- /dev/null +++ b/src/main/electron-app/features/sync-theme-from-operating-system.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import operatingSystemThemeStateInjectable from "../../theme/operating-system-theme-state.injectable"; +import nativeThemeInjectable from "./native-theme.injectable"; +import getElectronThemeInjectable from "./get-electron-theme.injectable"; + +const syncThemeFromOperatingSystemInjectable = getInjectable({ + id: "sync-theme-from-operating-system", + + instantiate: (di) => { + const currentThemeState = di.inject(operatingSystemThemeStateInjectable); + const nativeTheme = di.inject(nativeThemeInjectable); + const getElectronTheme = di.inject(getElectronThemeInjectable); + + return getStartableStoppable("sync-theme-from-operating-system", () => { + const updateThemeState = () => { + const newTheme = getElectronTheme(); + + currentThemeState.set(newTheme); + }; + + nativeTheme.on("updated", updateThemeState); + + return () => { + nativeTheme.off("updated", updateThemeState); + }; + }); + }, +}); + +export default syncThemeFromOperatingSystemInjectable; diff --git a/src/main/electron-app/features/wait-for-electron-to-be-ready.injectable.ts b/src/main/electron-app/features/wait-for-electron-to-be-ready.injectable.ts new file mode 100644 index 0000000000..586e8d7ac2 --- /dev/null +++ b/src/main/electron-app/features/wait-for-electron-to-be-ready.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; + +const waitForElectronToBeReadyInjectable = getInjectable({ + id: "wait-for-electron-to-be-ready", + + instantiate: (di) => () => di.inject(electronAppInjectable).whenReady(), +}); + +export default waitForElectronToBeReadyInjectable; diff --git a/src/main/electron-app/runnables/clean-up-deep-linking.injectable.ts b/src/main/electron-app/runnables/clean-up-deep-linking.injectable.ts new file mode 100644 index 0000000000..5925197db3 --- /dev/null +++ b/src/main/electron-app/runnables/clean-up-deep-linking.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import lensProtocolRouterMainInjectable from "../../protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; + +const cleanUpDeepLinkingInjectable = getInjectable({ + id: "clean-up-deep-linking", + + instantiate: (di) => { + const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); + + return { + run: () => { + lensProtocolRouterMain.cleanup(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default cleanUpDeepLinkingInjectable; diff --git a/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts b/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts new file mode 100644 index 0000000000..de4b2b357d --- /dev/null +++ b/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../../../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token"; +import electronAppInjectable from "../../electron-app.injectable"; +import { lensWindowInjectionToken } from "../../../start-main-application/lens-window/application-window/lens-window-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, isEmpty } from "lodash/fp"; + +const hideDockForLastClosedWindowInjectable = getInjectable({ + id: "hide-dock-when-there-are-no-windows", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + const getLensWindows = () => di.injectMany(lensWindowInjectionToken); + + return { + run: () => { + const visibleWindows = pipeline( + getLensWindows(), + filter(window => !!window.visible), + ); + + if (isEmpty(visibleWindows)) { + app.dock?.hide(); + } + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default hideDockForLastClosedWindowInjectable; diff --git a/src/main/electron-app/runnables/dock-visibility/show-dock-for-first-opened-window.injectable.ts b/src/main/electron-app/runnables/dock-visibility/show-dock-for-first-opened-window.injectable.ts new file mode 100644 index 0000000000..1dbafb60f5 --- /dev/null +++ b/src/main/electron-app/runnables/dock-visibility/show-dock-for-first-opened-window.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../../electron-app.injectable"; +import { afterWindowIsOpenedInjectionToken } from "../../../start-main-application/runnable-tokens/after-window-is-opened-injection-token"; + +const showDockForFirstOpenedWindowInjectable = getInjectable({ + id: "show-dock-for-first-opened-window", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + + return { + run: () => { + app.dock?.show(); + }, + }; + }, + + injectionToken: afterWindowIsOpenedInjectionToken, +}); + +export default showDockForFirstOpenedWindowInjectable; diff --git a/src/main/electron-app/runnables/enforce-single-application-instance.injectable.ts b/src/main/electron-app/runnables/enforce-single-application-instance.injectable.ts new file mode 100644 index 0000000000..6908b6ac47 --- /dev/null +++ b/src/main/electron-app/runnables/enforce-single-application-instance.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeElectronIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/before-electron-is-ready-injection-token"; +import requestSingleInstanceLockInjectable from "../features/request-single-instance-lock.injectable"; +import exitAppInjectable from "../features/exit-app.injectable"; + +const enforceSingleApplicationInstanceInjectable = getInjectable({ + id: "enforce-single-application-instance", + + instantiate: (di) => { + const requestSingleInstanceLock = di.inject(requestSingleInstanceLockInjectable); + const exitApp = di.inject(exitAppInjectable); + + return { + run: () => { + if (!requestSingleInstanceLock()) { + exitApp(); + } + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default enforceSingleApplicationInstanceInjectable; diff --git a/src/main/electron-app/runnables/setup-application-name.injectable.ts b/src/main/electron-app/runnables/setup-application-name.injectable.ts new file mode 100644 index 0000000000..a5f85301c0 --- /dev/null +++ b/src/main/electron-app/runnables/setup-application-name.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appNameInjectable from "../../app-paths/app-name/app-name.injectable"; +import { beforeElectronIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/before-electron-is-ready-injection-token"; +import electronAppInjectable from "../electron-app.injectable"; + +const setupApplicationNameInjectable = getInjectable({ + id: "setup-application-name", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + const appName = di.inject(appNameInjectable); + + return { + run: () => { + app.setName(appName); + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupApplicationNameInjectable; diff --git a/src/main/electron-app/runnables/setup-deep-linking.injectable.ts b/src/main/electron-app/runnables/setup-deep-linking.injectable.ts new file mode 100644 index 0000000000..47087cd1c1 --- /dev/null +++ b/src/main/electron-app/runnables/setup-deep-linking.injectable.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; +import openDeepLinkInjectable from "../../protocol-handler/lens-protocol-router-main/open-deep-link-for-url/open-deep-link.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import commandLineArgumentsInjectable from "../../utils/command-line-arguments.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import { find, startsWith, toLower, map } from "lodash/fp"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; + +const setupDeepLinkingInjectable = getInjectable({ + id: "setup-deep-linking", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + const logger = di.inject(loggerInjectable); + const openDeepLinkForUrl = di.inject(openDeepLinkInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + + const firstInstanceCommandLineArguments = di.inject( + commandLineArgumentsInjectable, + ); + + return { + run: async () => { + logger.info(`📟 Setting protocol client for lens://`); + + if (app.setAsDefaultProtocolClient("lens")) { + logger.info("📟 Protocol client register succeeded ✅"); + } else { + logger.info("📟 Protocol client register failed ❗"); + } + + const url = getDeepLinkUrl(firstInstanceCommandLineArguments); + + if (url) { + await openDeepLinkForUrl(url); + } + + app.on("open-url", async (event, url) => { + event.preventDefault(); + + await openDeepLinkForUrl(url); + }); + + app.on( + "second-instance", + + async (_, secondInstanceCommandLineArguments) => { + const url = getDeepLinkUrl(secondInstanceCommandLineArguments); + + await showApplicationWindow(); + + if (url) { + await openDeepLinkForUrl(url); + } + }, + ); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupDeepLinkingInjectable; + +const getDeepLinkUrl = (commandLineArguments: string[]) => + pipeline(commandLineArguments, map(toLower), find(startsWith("lens://"))); diff --git a/src/main/electron-app/runnables/setup-developer-tools-in-development-environment.injectable.ts b/src/main/electron-app/runnables/setup-developer-tools-in-development-environment.injectable.ts new file mode 100644 index 0000000000..3438bc1428 --- /dev/null +++ b/src/main/electron-app/runnables/setup-developer-tools-in-development-environment.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; + +const setupDeveloperToolsInDevelopmentEnvironmentInjectable = getInjectable({ + id: "setup-developer-tools-in-development-environment", + + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + return { + run: () => { + if (process.env.NODE_ENV !== "development") { + return; + } + + logger.info("🤓 Installing developer tools"); + + import("electron-devtools-installer") + .then(({ default: devToolsInstaller, REACT_DEVELOPER_TOOLS }) => + devToolsInstaller([REACT_DEVELOPER_TOOLS]), + ) + .then((name) => + logger.info(`[DEVTOOLS-INSTALLER]: installed ${name}`), + ) + .catch((error) => + logger.error(`[DEVTOOLS-INSTALLER]: failed`, { error }), + ); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupDeveloperToolsInDevelopmentEnvironmentInjectable; diff --git a/src/main/electron-app/runnables/setup-device-shutdown.injectable.ts b/src/main/electron-app/runnables/setup-device-shutdown.injectable.ts new file mode 100644 index 0000000000..b0b1ee8096 --- /dev/null +++ b/src/main/electron-app/runnables/setup-device-shutdown.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import powerMonitorInjectable from "../features/power-monitor.injectable"; +import exitAppInjectable from "../features/exit-app.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; + +const setupDeviceShutdownInjectable = getInjectable({ + id: "setup-device-shutdown", + + instantiate: (di) => { + const powerMonitor = di.inject(powerMonitorInjectable); + const exitApp = di.inject(exitAppInjectable); + + return { + run: () => { + powerMonitor.on("shutdown", async () => { + exitApp(); + }); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupDeviceShutdownInjectable; diff --git a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts new file mode 100644 index 0000000000..24f90f01b3 --- /dev/null +++ b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForLensLocalStorageInjectable from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; +import { setupIpcMainHandlers } from "./setup-ipc-main-handlers"; +import loggerInjectable from "../../../../common/logger.injectable"; +import clusterManagerInjectable from "../../../cluster-manager.injectable"; +import applicationMenuItemsInjectable from "../../../menu/application-menu-items.injectable"; +import getAbsolutePathInjectable from "../../../../common/path/get-absolute-path.injectable"; +import clusterStoreInjectable from "../../../../common/cluster-store/cluster-store.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import operatingSystemThemeInjectable from "../../../theme/operating-system-theme.injectable"; +import catalogEntityRegistryInjectable from "../../../catalog/entity-registry.injectable"; +import askUserForFilePathsInjectable from "../../../ipc/ask-user-for-file-paths.injectable"; + +const setupIpcMainHandlersInjectable = getInjectable({ + id: "setup-ipc-main-handlers", + + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + const directoryForLensLocalStorage = di.inject( + directoryForLensLocalStorageInjectable, + ); + + const clusterManager = di.inject(clusterManagerInjectable); + const applicationMenuItems = di.inject(applicationMenuItemsInjectable); + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + const clusterStore = di.inject(clusterStoreInjectable); + const operatingSystemTheme = di.inject(operatingSystemThemeInjectable); + const askUserForFilePaths = di.inject(askUserForFilePathsInjectable); + + return { + run: () => { + logger.debug("[APP-MAIN] initializing ipc main handlers"); + + setupIpcMainHandlers({ + applicationMenuItems, + getAbsolutePath, + directoryForLensLocalStorage, + clusterManager, + catalogEntityRegistry, + clusterStore, + operatingSystemTheme, + askUserForFilePaths, + }); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, + causesSideEffects: true, +}); + +export default setupIpcMainHandlersInjectable; diff --git a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts similarity index 69% rename from src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts rename to src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts index 1a657b78be..9342696c77 100644 --- a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts +++ b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts @@ -2,37 +2,41 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - import type { IpcMainInvokeEvent } from "electron"; import { BrowserWindow, Menu } from "electron"; -import { clusterFrameMap } from "../../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../../common/ipc/cluster"; -import type { ClusterId } from "../../../common/cluster-types"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { appEventBus } from "../../../common/app-event-bus/event-bus"; -import { broadcastMainChannel, broadcastMessage, ipcMainHandle, ipcMainOn } from "../../../common/ipc"; -import { catalogEntityRegistry } from "../../catalog"; -import { pushCatalogToRenderer } from "../../catalog-pusher"; -import { ClusterManager } from "../../cluster-manager"; -import { ResourceApplier } from "../../resource-applier"; +import { clusterFrameMap } from "../../../../common/cluster-frames"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../../../common/ipc/cluster"; +import type { ClusterId } from "../../../../common/cluster-types"; +import { ClusterStore } from "../../../../common/cluster-store/cluster-store"; +import { appEventBus } from "../../../../common/app-event-bus/event-bus"; +import { broadcastMainChannel, broadcastMessage, ipcMainHandle, ipcMainOn } from "../../../../common/ipc"; +import type { CatalogEntityRegistry } from "../../../catalog"; +import { pushCatalogToRenderer } from "../../../catalog-pusher"; +import type { ClusterManager } from "../../../cluster-manager"; +import { ResourceApplier } from "../../../resource-applier"; import { remove } from "fs-extra"; -import { onLocationChange, handleWindowAction } from "../../ipc/window"; -import { openFilePickingDialogChannel } from "../../../common/ipc/dialog"; -import { showOpenDialog } from "../../ipc/dialog"; -import { windowActionHandleChannel, windowLocationChangedChannel, windowOpenAppMenuAsContextMenuChannel } from "../../../common/ipc/window"; -import { getNativeColorTheme } from "../../native-theme"; -import { getNativeThemeChannel } from "../../../common/ipc/native-theme"; -import type { GetAbsolutePath } from "../../../common/path/get-absolute-path.injectable"; import type { IComputedValue } from "mobx"; -import type { MenuItemOpts } from "../../menu/application-menu-items.injectable"; +import type { GetAbsolutePath } from "../../../../common/path/get-absolute-path.injectable"; +import type { MenuItemOpts } from "../../../menu/application-menu-items.injectable"; +import { windowActionHandleChannel, windowLocationChangedChannel, windowOpenAppMenuAsContextMenuChannel } from "../../../../common/ipc/window"; +import { handleWindowAction, onLocationChange } from "../../../ipc/window"; +import { openFilePickingDialogChannel } from "../../../../common/ipc/dialog"; +import { getNativeThemeChannel } from "../../../../common/ipc/native-theme"; +import type { Theme } from "../../../theme/operating-system-theme-state.injectable"; +import type { AskUserForFilePaths } from "../../../ipc/ask-user-for-file-paths.injectable"; interface Dependencies { directoryForLensLocalStorage: string; getAbsolutePath: GetAbsolutePath; applicationMenuItems: IComputedValue; + clusterManager: ClusterManager; + catalogEntityRegistry: CatalogEntityRegistry; + clusterStore: ClusterStore; + operatingSystemTheme: IComputedValue; + askUserForFilePaths: AskUserForFilePaths; } -export const initIpcMainHandlers = ({ applicationMenuItems, directoryForLensLocalStorage, getAbsolutePath }: Dependencies) => () => { +export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLocalStorage, getAbsolutePath, clusterManager, catalogEntityRegistry, clusterStore, operatingSystemTheme, askUserForFilePaths }: Dependencies) => { ipcMainHandle(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { return ClusterStore.getInstance() .getById(clusterId) @@ -51,7 +55,7 @@ export const initIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoca }); ipcMainOn(clusterVisibilityHandler, (event, clusterId?: ClusterId) => { - ClusterManager.getInstance().visibleCluster = clusterId; + clusterManager.visibleCluster = clusterId; }); ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => { @@ -97,11 +101,11 @@ export const initIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoca }); ipcMainHandle(clusterSetDeletingHandler, (event, clusterId: string) => { - ClusterManager.getInstance().deleting.add(clusterId); + clusterManager.deleting.add(clusterId); }); ipcMainHandle(clusterClearDeletingHandler, (event, clusterId: string) => { - ClusterManager.getInstance().deleting.delete(clusterId); + clusterManager.deleting.delete(clusterId); }); ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { @@ -146,7 +150,7 @@ export const initIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoca ipcMainOn(windowLocationChangedChannel, () => onLocationChange()); - ipcMainHandle(openFilePickingDialogChannel, (event, opts) => showOpenDialog(opts)); + ipcMainHandle(openFilePickingDialogChannel, (event, opts) => askUserForFilePaths(opts)); ipcMainHandle(broadcastMainChannel, (event, channel, ...args) => broadcastMessage(channel, ...args)); @@ -164,6 +168,8 @@ export const initIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoca }); ipcMainHandle(getNativeThemeChannel, () => { - return getNativeColorTheme(); + return operatingSystemTheme.get(); }); + + clusterStore.provideInitialFromMain(); }; diff --git a/src/main/electron-app/runnables/setup-main-window-visibility-after-activation.injectable.ts b/src/main/electron-app/runnables/setup-main-window-visibility-after-activation.injectable.ts new file mode 100644 index 0000000000..3642fa3c6d --- /dev/null +++ b/src/main/electron-app/runnables/setup-main-window-visibility-after-activation.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronAppInjectable from "../electron-app.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; + +const setupMainWindowVisibilityAfterActivationInjectable = getInjectable({ + id: "setup-main-window-visibility-after-activation", + + instantiate: (di) => { + const app = di.inject(electronAppInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const logger = di.inject(loggerInjectable); + + return { + run: () => { + app.on("activate", async (_, windowIsVisible) => { + logger.info("APP:ACTIVATE", { hasVisibleWindows: windowIsVisible }); + + if (!windowIsVisible) { + await showApplicationWindow(); + } + }); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupMainWindowVisibilityAfterActivationInjectable; diff --git a/src/main/electron-app/runnables/setup-runnables-after-window-is-opened.injectable.ts b/src/main/electron-app/runnables/setup-runnables-after-window-is-opened.injectable.ts new file mode 100644 index 0000000000..8e724fdf50 --- /dev/null +++ b/src/main/electron-app/runnables/setup-runnables-after-window-is-opened.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeElectronIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/before-electron-is-ready-injection-token"; +import electronAppInjectable from "../electron-app.injectable"; +import { runManyFor } from "../../../common/runnable/run-many-for"; +import { afterWindowIsOpenedInjectionToken } from "../../start-main-application/runnable-tokens/after-window-is-opened-injection-token"; + +const setupRunnablesAfterWindowIsOpenedInjectable = getInjectable({ + id: "setup-runnables-after-window-is-opened", + + instantiate: (di) => { + const afterWindowIsOpened = runManyFor(di)(afterWindowIsOpenedInjectionToken); + + return { + run: () => { + const app = di.inject(electronAppInjectable); + + app.on("browser-window-created", async () => { + await afterWindowIsOpened(); + }); + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupRunnablesAfterWindowIsOpenedInjectable; diff --git a/src/main/electron-app/runnables/setup-runnables-before-closing-of-application.injectable.ts b/src/main/electron-app/runnables/setup-runnables-before-closing-of-application.injectable.ts new file mode 100644 index 0000000000..bb2c8f8970 --- /dev/null +++ b/src/main/electron-app/runnables/setup-runnables-before-closing-of-application.injectable.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeElectronIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/before-electron-is-ready-injection-token"; +import { beforeQuitOfFrontEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import electronAppInjectable from "../electron-app.injectable"; +import isIntegrationTestingInjectable from "../../../common/vars/is-integration-testing.injectable"; +import autoUpdaterInjectable from "../features/auto-updater.injectable"; +import { runManySyncFor } from "../../../common/runnable/run-many-sync-for"; + +const setupRunnablesBeforeClosingOfApplicationInjectable = getInjectable({ + id: "setup-closing-of-application", + + instantiate: (di) => { + const runMany = runManySyncFor(di); + + const runRunnablesBeforeQuitOfFrontEnd = runMany( + beforeQuitOfFrontEndInjectionToken, + ); + + const runRunnablesBeforeQuitOfBackEnd = runMany( + beforeQuitOfBackEndInjectionToken, + ); + + return { + run: () => { + const app = di.inject(electronAppInjectable); + + const isIntegrationTesting = di.inject(isIntegrationTestingInjectable); + const autoUpdater = di.inject(autoUpdaterInjectable); + + let isAutoUpdating = false; + + autoUpdater.on("before-quit-for-update", () => { + isAutoUpdating = true; + }); + + app.on("will-quit", (event) => { + runRunnablesBeforeQuitOfFrontEnd(); + + const shouldQuitBackEnd = isIntegrationTesting || isAutoUpdating; + + if (shouldQuitBackEnd) { + runRunnablesBeforeQuitOfBackEnd(); + } else { + // IMPORTANT: This cannot be destructured as it would break binding of "this" for the Electron event + event.preventDefault(); + } + }); + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupRunnablesBeforeClosingOfApplicationInjectable; diff --git a/src/main/exit-app.ts b/src/main/exit-app.ts deleted file mode 100644 index c9a5bc5bf1..0000000000 --- a/src/main/exit-app.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { app } from "electron"; -import { WindowManager } from "./window-manager"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { ClusterManager } from "./cluster-manager"; -import logger from "./logger"; - -export function exitApp() { - const windowManager = WindowManager.getInstance(false); - const clusterManager = ClusterManager.getInstance(false); - - appEventBus.emit({ name: "service", action: "close" }); - windowManager?.hide(); - clusterManager?.stop(); - logger.info("SERVICE:QUIT"); - setTimeout(() => { - app.exit(); - }, 1000); -} diff --git a/src/main/extension-loader/create-extension-instance.injectable.ts b/src/main/extension-loader/create-extension-instance.injectable.ts new file mode 100644 index 0000000000..5617632580 --- /dev/null +++ b/src/main/extension-loader/create-extension-instance.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { createExtensionInstanceInjectionToken } from "../../extensions/extension-loader/create-extension-instance.token"; +import fileSystemProvisionerStoreInjectable from "../../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable"; +import { lensExtensionDependencies } from "../../extensions/lens-extension"; +import type { LensMainExtensionDependencies } from "../../extensions/lens-extension-set-dependencies"; +import type { LensMainExtension } from "../../extensions/lens-main-extension"; +import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; +import navigateForExtensionInjectable from "../start-main-application/lens-window/navigate-for-extension.injectable"; + +const createExtensionInstanceInjectable = getInjectable({ + id: "create-extension-instance", + instantiate: (di) => { + const deps: LensMainExtensionDependencies = { + fileSystemProvisionerStore: di.inject(fileSystemProvisionerStoreInjectable), + entityRegistry: di.inject(catalogEntityRegistryInjectable), + navigate: di.inject(navigateForExtensionInjectable), + }; + + return (ExtensionClass, extension) => { + const instance = new ExtensionClass(extension) as LensMainExtension; + + (instance as { [lensExtensionDependencies]: LensMainExtensionDependencies })[lensExtensionDependencies] = deps; + + return instance; + }; + }, + injectionToken: createExtensionInstanceInjectionToken, +}); + +export default createExtensionInstanceInjectable; diff --git a/src/main/get-metrics.injectable.ts b/src/main/get-metrics.injectable.ts new file mode 100644 index 0000000000..4d6634909c --- /dev/null +++ b/src/main/get-metrics.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Cluster } from "../common/cluster/cluster"; +import type { IMetricsReqParams } from "../common/k8s-api/endpoints/metrics.api"; +import k8sRequestInjectable from "./k8s-request.injectable"; + +export type GetMetrics = (cluster: Cluster, prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) => Promise; + +const getMetricsInjectable = getInjectable({ + id: "get-metrics", + + instantiate: (di): GetMetrics => { + const k8sRequest = di.inject(k8sRequestInjectable); + + return async ( + cluster, + prometheusPath, + queryParams, + ) => { + const prometheusPrefix = cluster.preferences.prometheus?.prefix || ""; + const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; + + return k8sRequest(cluster, metricsPath, { + timeout: 0, + resolveWithFullResponse: false, + json: true, + method: "POST", + form: queryParams, + }); + }; + }, +}); + +export default getMetricsInjectable; diff --git a/src/main/getDi.ts b/src/main/getDi.ts index d6f76122b7..9d9c60bbe8 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -4,25 +4,22 @@ */ import { createContainer } from "@ogre-tools/injectable"; +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; export const getDi = () => { - const di = createContainer( - getRequireContextForMainCode, - getRequireContextForCommonExtensionCode, - getRequireContextForCommonCode, - ); + const di = createContainer(); + + autoRegister({ + di, + requireContexts: [ + require.context("./", true, /\.injectable\.(ts|tsx)$/), + require.context("../extensions", true, /\.injectable\.(ts|tsx)$/), + require.context("../common", true, /\.injectable\.(ts|tsx)$/), + ], + }); setLegacyGlobalDiForExtensionApi(di, Environments.main); return di; }; - -const getRequireContextForMainCode = () => - require.context("./", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonExtensionCode = () => - require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonCode = () => - require.context("../common", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index b1306457c5..2cc78ec528 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -5,40 +5,91 @@ import glob from "glob"; import { kebabCase, memoize, noop } from "lodash/fp"; +import type { DiContainer } from "@ogre-tools/injectable"; import { createContainer } from "@ogre-tools/injectable"; - import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import getElectronAppPathInjectable from "./app-paths/get-electron-app-path/get-electron-app-path.injectable"; -import setElectronAppPathInjectable from "./app-paths/set-electron-app-path/set-electron-app-path.injectable"; import appNameInjectable from "./app-paths/app-name/app-name.injectable"; -import registerChannelInjectable from "./app-paths/register-channel/register-channel.injectable"; import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; import readJsonFileInjectable from "../common/fs/read-json-file.injectable"; import readFileInjectable from "../common/fs/read-file.injectable"; -import directoryForBundledBinariesInjectable from "../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; import loggerInjectable from "../common/logger.injectable"; import spawnInjectable from "./child-process/spawn.injectable"; import extensionsStoreInjectable from "../extensions/extensions-store/extensions-store.injectable"; import type { ExtensionsStore } from "../extensions/extensions-store/extensions-store"; -import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable"; -import type { FileSystemProvisionerStore } from "../extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store"; +import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable"; +import type { FileSystemProvisionerStore } from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store"; import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; import type { ClusterStore } from "../common/cluster-store/cluster-store"; import type { Cluster } from "../common/cluster/cluster"; import userStoreInjectable from "../common/user-store/user-store.injectable"; import type { UserStore } from "../common/user-store"; -import isMacInjectable from "../common/vars/is-mac.injectable"; -import isWindowsInjectable from "../common/vars/is-windows.injectable"; -import isLinuxInjectable from "../common/vars/is-linux.injectable"; import getAbsolutePathInjectable from "../common/path/get-absolute-path.injectable"; import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake"; import joinPathsInjectable from "../common/path/join-paths.injectable"; import { joinPathsFake } from "../common/test-utils/join-paths-fake"; -import hotbarStoreInjectable from "../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../common/hotbars/store.injectable"; +import appEventBusInjectable from "../common/app-event-bus/app-event-bus.injectable"; +import { EventEmitter } from "../common/event-emitter"; +import type { AppEvent } from "../common/app-event-bus/event-bus"; +import commandLineArgumentsInjectable from "./utils/command-line-arguments.injectable"; +import initializeExtensionsInjectable from "./start-main-application/runnables/initialize-extensions.injectable"; +import lensResourcesDirInjectable from "../common/vars/lens-resources-dir.injectable"; +import environmentVariablesInjectable from "../common/utils/environment-variables.injectable"; +import setupIpcMainHandlersInjectable from "./electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable"; +import setupLensProxyInjectable from "./start-main-application/runnables/setup-lens-proxy.injectable"; +import setupRunnablesForAfterRootFrameIsReadyInjectable from "./start-main-application/runnables/setup-runnables-for-after-root-frame-is-ready.injectable"; +import setupSentryInjectable from "./start-main-application/runnables/setup-sentry.injectable"; +import setupShellInjectable from "./start-main-application/runnables/setup-shell.injectable"; +import setupSyncingOfWeblinksInjectable from "./start-main-application/runnables/setup-syncing-of-weblinks.injectable"; +import stopServicesAndExitAppInjectable from "./stop-services-and-exit-app.injectable"; +import applicationMenuInjectable from "./menu/application-menu.injectable"; +import isDevelopmentInjectable from "../common/vars/is-development.injectable"; +import setupSystemCaInjectable from "./start-main-application/runnables/setup-system-ca.injectable"; +import setupDeepLinkingInjectable from "./electron-app/runnables/setup-deep-linking.injectable"; +import exitAppInjectable from "./electron-app/features/exit-app.injectable"; +import getCommandLineSwitchInjectable from "./electron-app/features/get-command-line-switch.injectable"; +import requestSingleInstanceLockInjectable from "./electron-app/features/request-single-instance-lock.injectable"; +import disableHardwareAccelerationInjectable from "./electron-app/features/disable-hardware-acceleration.injectable"; +import shouldStartHiddenInjectable from "./electron-app/features/should-start-hidden.injectable"; +import getElectronAppPathInjectable from "./app-paths/get-electron-app-path/get-electron-app-path.injectable"; +import setElectronAppPathInjectable from "./app-paths/set-electron-app-path/set-electron-app-path.injectable"; +import setupMainWindowVisibilityAfterActivationInjectable from "./electron-app/runnables/setup-main-window-visibility-after-activation.injectable"; +import setupDeviceShutdownInjectable from "./electron-app/runnables/setup-device-shutdown.injectable"; +import setupApplicationNameInjectable from "./electron-app/runnables/setup-application-name.injectable"; +import setupRunnablesBeforeClosingOfApplicationInjectable from "./electron-app/runnables/setup-runnables-before-closing-of-application.injectable"; +import showMessagePopupInjectable from "./electron-app/features/show-message-popup.injectable"; +import clusterFramesInjectable from "../common/cluster-frames.injectable"; +import type { ClusterFrameInfo } from "../common/cluster-frames"; +import { observable } from "mobx"; +import waitForElectronToBeReadyInjectable from "./electron-app/features/wait-for-electron-to-be-ready.injectable"; +import setupListenerForCurrentClusterFrameInjectable from "./start-main-application/lens-window/current-cluster-frame/setup-listener-for-current-cluster-frame.injectable"; +import ipcMainInjectable from "./utils/channel/ipc-main/ipc-main.injectable"; +import createElectronWindowForInjectable from "./start-main-application/lens-window/application-window/create-electron-window-for.injectable"; +import setupRunnablesAfterWindowIsOpenedInjectable from "./electron-app/runnables/setup-runnables-after-window-is-opened.injectable"; +import sendToChannelInElectronBrowserWindowInjectable from "./start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; +import broadcastMessageInjectable from "../common/ipc/broadcast-message.injectable"; +import getElectronThemeInjectable from "./electron-app/features/get-electron-theme.injectable"; +import syncThemeFromOperatingSystemInjectable from "./electron-app/features/sync-theme-from-operating-system.injectable"; +import platformInjectable from "../common/vars/platform.injectable"; +import productNameInjectable from "./app-paths/app-name/product-name.injectable"; +import quitAndInstallUpdateInjectable from "./electron-app/features/quit-and-install-update.injectable"; +import electronUpdaterIsActiveInjectable from "./electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "./application-update/publish-is-configured.injectable"; +import checkForPlatformUpdatesInjectable from "./application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import baseBundeledBinariesDirectoryInjectable from "../common/vars/base-bundled-binaries-dir.injectable"; +import setUpdateOnQuitInjectable from "./electron-app/features/set-update-on-quit.injectable"; +import downloadPlatformUpdateInjectable from "./application-update/download-platform-update/download-platform-update.injectable"; +import startCatalogSyncInjectable from "./catalog-sync-to-renderer/start-catalog-sync.injectable"; +import startKubeConfigSyncInjectable from "./start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable"; +import appVersionInjectable from "../common/get-configuration-file-model/app-version/app-version.injectable"; +import getRandomIdInjectable from "../common/utils/get-random-id.injectable"; +import periodicalCheckForUpdatesInjectable from "./application-update/periodical-check-for-updates/periodical-check-for-updates.injectable"; + +export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) { + const { + doGeneralOverrides = false, + } = opts; -export const getDiForUnitTesting = ( - { doGeneralOverrides } = { doGeneralOverrides: false }, -) => { const di = createContainer(); setLegacyGlobalDiForExtensionApi(di, Environments.main); @@ -56,40 +107,47 @@ export const getDiForUnitTesting = ( di.preventSideEffects(); if (doGeneralOverrides) { - di.override(isMacInjectable, () => true); - di.override(isWindowsInjectable, () => false); - di.override(isLinuxInjectable, () => false); - - di.override(getAbsolutePathInjectable, () => getAbsolutePathFake); - di.override(joinPathsInjectable, () => joinPathsFake); - - // eslint-disable-next-line unused-imports/no-unused-vars-ts - di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore); - - di.override(hotbarStoreInjectable, () => ({})); - + di.override(getRandomIdInjectable, () => () => "some-irrelevant-random-id"); + di.override(hotbarStoreInjectable, () => ({ load: () => {} })); + di.override(userStoreInjectable, () => ({ startMainReactions: () => {}, extensionRegistryUrl: { customUrl: "some-custom-url" }}) as UserStore); + di.override(extensionsStoreInjectable, () => ({ isEnabled: (opts) => (void opts, false) }) as ExtensionsStore); + di.override(clusterStoreInjectable, () => ({ provideInitialFromMain: () => {}, getById: (id) => (void id, {}) as Cluster }) as ClusterStore); di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore); - // eslint-disable-next-line unused-imports/no-unused-vars-ts - di.override(clusterStoreInjectable, () => ({ getById: (id): Cluster => ({}) as Cluster }) as ClusterStore); - di.override(userStoreInjectable, () => ({}) as UserStore); + overrideOperatingSystem(di); + overrideRunnablesHavingSideEffects(di); + overrideElectronFeatures(di); - di.override( - getElectronAppPathInjectable, - () => (name: string) => `some-electron-app-path-for-${kebabCase(name)}`, - ); + di.override(isDevelopmentInjectable, () => false); + di.override(environmentVariablesInjectable, () => ({})); + di.override(commandLineArgumentsInjectable, () => []); - di.override(setElectronAppPathInjectable, () => () => undefined); - di.override(appNameInjectable, () => "some-electron-app-name"); - di.override(registerChannelInjectable, () => () => undefined); - di.override(directoryForBundledBinariesInjectable, () => "some-bin-directory"); + di.override(productNameInjectable, () => "some-product-name"); + di.override(appVersionInjectable, () => "1.0.0"); + di.override(clusterFramesInjectable, () => observable.map()); + + di.override(stopServicesAndExitAppInjectable, () => () => {}); + di.override(lensResourcesDirInjectable, () => "/irrelevant"); + + di.override(applicationMenuInjectable, () => ({ start: () => {}, stop: () => {} })); + + di.override(periodicalCheckForUpdatesInjectable, () => ({ start: () => {}, stop: () => {}, started: false })); + + // TODO: Remove usages of globally exported appEventBus to get rid of this + di.override(appEventBusInjectable, () => new EventEmitter<[AppEvent]>()); + + di.override(appNameInjectable, () => "some-app-name"); + di.override(broadcastMessageInjectable, () => (channel) => { + throw new Error(`Tried to broadcast message to channel "${channel}" over IPC without explicit override.`); + }); + di.override(baseBundeledBinariesDirectoryInjectable, () => "some-bin-directory"); di.override(spawnInjectable, () => () => { return { stderr: { on: jest.fn(), removeAllListeners: jest.fn() }, stdout: { on: jest.fn(), removeAllListeners: jest.fn() }, on: jest.fn(), - } as any; + } as never; }); di.override(writeJsonFileInjectable, () => () => { @@ -107,16 +165,95 @@ export const getDiForUnitTesting = ( di.override(loggerInjectable, () => ({ warn: noop, debug: noop, - error: (message: string, ...args: any) => console.error(message, ...args), + error: noop, info: noop, + silly: noop, })); } return di; -}; +} const getInjectableFilePaths = memoize(() => [ ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), ...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), ...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }), ]); + +// TODO: Reorganize code in Runnables to get rid of requirement for override +const overrideRunnablesHavingSideEffects = (di: DiContainer) => { + [ + initializeExtensionsInjectable, + setupIpcMainHandlersInjectable, + setupLensProxyInjectable, + setupRunnablesForAfterRootFrameIsReadyInjectable, + setupSentryInjectable, + setupShellInjectable, + setupSyncingOfWeblinksInjectable, + setupSystemCaInjectable, + setupListenerForCurrentClusterFrameInjectable, + setupRunnablesAfterWindowIsOpenedInjectable, + startCatalogSyncInjectable, + startKubeConfigSyncInjectable, + ].forEach((injectable) => { + di.override(injectable, () => ({ run: () => {} })); + }); +}; + +const overrideOperatingSystem = (di: DiContainer) => { + di.override(platformInjectable, () => "darwin"); + di.override(getAbsolutePathInjectable, () => getAbsolutePathFake); + di.override(joinPathsInjectable, () => joinPathsFake); +}; + +const overrideElectronFeatures = (di: DiContainer) => { + di.override(setupMainWindowVisibilityAfterActivationInjectable, () => ({ + run: () => {}, + })); + + di.override(setupDeviceShutdownInjectable, () => ({ + run: () => {}, + })); + + di.override(setupDeepLinkingInjectable, () => ({ run: () => {} })); + di.override(exitAppInjectable, () => () => {}); + di.override(setupApplicationNameInjectable, () => ({ run: () => {} })); + di.override(setupRunnablesBeforeClosingOfApplicationInjectable, () => ({ run: () => {} })); + di.override(getCommandLineSwitchInjectable, () => () => "irrelevant"); + di.override(requestSingleInstanceLockInjectable, () => () => true); + di.override(disableHardwareAccelerationInjectable, () => () => {}); + di.override(shouldStartHiddenInjectable, () => true); + di.override(showMessagePopupInjectable, () => () => {}); + di.override(waitForElectronToBeReadyInjectable, () => () => Promise.resolve()); + di.override(ipcMainInjectable, () => ({})); + di.override(getElectronThemeInjectable, () => () => "dark"); + di.override(syncThemeFromOperatingSystemInjectable, () => ({ start: () => {}, stop: () => {} })); + di.override(quitAndInstallUpdateInjectable, () => () => {}); + di.override(setUpdateOnQuitInjectable, () => () => {}); + di.override(downloadPlatformUpdateInjectable, () => async () => ({ downloadWasSuccessful: true })); + + di.override(checkForPlatformUpdatesInjectable, () => () => { + throw new Error("Tried to check for platform updates without explicit override."); + }); + + di.override(createElectronWindowForInjectable, () => () => async () => ({ + show: () => {}, + + close: () => {}, + + send: (arg) => { + const sendFake = di.inject(sendToChannelInElectronBrowserWindowInjectable) as any; + + sendFake(null, arg); + }, + })); + + di.override( + getElectronAppPathInjectable, + () => (name: string) => `some-electron-app-path-for-${kebabCase(name)}`, + ); + + di.override(setElectronAppPathInjectable, () => () => {}); + di.override(publishIsConfiguredInjectable, () => false); + di.override(electronUpdaterIsActiveInjectable, () => false); +}; diff --git a/src/main/helm/__tests__/helm-service.test.ts b/src/main/helm/__tests__/helm-service.test.ts index 42c548c4be..d9e6532e5c 100644 --- a/src/main/helm/__tests__/helm-service.test.ts +++ b/src/main/helm/__tests__/helm-service.test.ts @@ -17,14 +17,11 @@ describe("Helm Service tests", () => { it("list charts with deprecated entries", async () => { mockHelmRepoManager.mockReturnValue({ - init: jest.fn(), - repositories: jest.fn().mockImplementation(async () => { - return [ - { name: "stable", url: "stableurl" }, - { name: "experiment", url: "experimenturl" }, - ]; - }), - }); + repositories: jest.fn().mockImplementation(async () => [ + { name: "stable", url: "stableurl" }, + { name: "experiment", url: "experimenturl" }, + ]), + } as any); const charts = await helmService.listCharts(); @@ -127,13 +124,12 @@ describe("Helm Service tests", () => { it("list charts sorted by version in descending order", async () => { mockHelmRepoManager.mockReturnValue({ - init: jest.fn(), repositories: jest.fn().mockImplementation(async () => { return [ { name: "bitnami", url: "bitnamiurl" }, ]; }), - }); + } as any); const charts = await helmService.listCharts(); diff --git a/src/main/helm/exec.ts b/src/main/helm/exec.ts index 5d0305b2f6..aa5db805ba 100644 --- a/src/main/helm/exec.ts +++ b/src/main/helm/exec.ts @@ -5,19 +5,25 @@ import { promiseExecFile } from "../../common/utils/promise-exec"; import type { BaseEncodingOptions } from "fs"; -import type { ExecFileOptions } from "child_process"; +import type { ExecFileOptions, ExecFileOptionsWithStringEncoding } from "child_process"; import { helmBinaryPath } from "../../common/vars"; import { UserStore } from "../../common/user-store"; +import { isChildProcessError } from "../../common/utils"; /** * ExecFile the bundled helm CLI * @returns STDOUT */ -export async function execHelm(args: string[], options?: BaseEncodingOptions & ExecFileOptions): Promise { +export async function execHelm(args: string[], { encoding, ...rest }: BaseEncodingOptions & ExecFileOptions = {}): Promise { + const options: ExecFileOptionsWithStringEncoding = { + encoding: encoding ?? "utf-8", + ...rest, + }; + try { const opts = { ...options }; - opts.env ??= process.env; + opts.env ??= { ...process.env }; if (!opts.env.HTTPS_PROXY && UserStore.getInstance().httpsProxy) { opts.env.HTTPS_PROXY = UserStore.getInstance().httpsProxy; @@ -27,6 +33,10 @@ export async function execHelm(args: string[], options?: BaseEncodingOptions & E return stdout; } catch (error) { - throw error?.stderr || error; + if (isChildProcessError(error, "string")) { + throw error.stderr || error; + } + + throw error; } } diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index 5c15bfc6b8..981c8602e8 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -4,16 +4,17 @@ */ import fs from "fs"; -import v8 from "v8"; import * as yaml from "js-yaml"; import type { HelmRepo } from "./helm-repo-manager"; import logger from "../logger"; import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api"; -import { iter, sortCharts } from "../../common/utils"; +import { iter, put, sortCharts } from "../../common/utils"; import { execHelm } from "./exec"; +import type { SetRequired } from "type-fest"; +import { assert } from "console"; interface ChartCacheEntry { - data: Buffer; + data: string; // serialized JSON mtimeMs: number; } @@ -23,9 +24,15 @@ export interface HelmCacheFile { } export class HelmChartManager { - static #cache = new Map(); + static readonly #cache = new Map(); - private constructor(protected repo: HelmRepo) {} + protected readonly repo: SetRequired; + + private constructor(repo: HelmRepo) { + assert(repo.cacheFilePath, "CacheFilePath must be provided on the helm repo"); + + this.repo = repo as SetRequired; + } static forRepo(repo: HelmRepo) { return new this(repo); @@ -76,25 +83,30 @@ export class HelmChartManager { const normalized = normalizeHelmCharts(this.repo.name, data.entries); - HelmChartManager.#cache.set(this.repo.name, { - data: v8.serialize(normalized), - mtimeMs: cacheFileStats.mtimeMs, - }); + return put( + HelmChartManager.#cache, + this.repo.name, + { + data: JSON.stringify(normalized), + mtimeMs: cacheFileStats.mtimeMs, + }, + ); } protected async cachedYaml(): Promise { - if (!HelmChartManager.#cache.has(this.repo.name)) { - await this.updateYamlCache(); + let cacheEntry = HelmChartManager.#cache.get(this.repo.name); + + if (!cacheEntry) { + cacheEntry = await this.updateYamlCache(); } else { const newStats = await fs.promises.stat(this.repo.cacheFilePath); - const cacheEntry = HelmChartManager.#cache.get(this.repo.name); if (cacheEntry.mtimeMs < newStats.mtimeMs) { - await this.updateYamlCache(); + cacheEntry = await this.updateYamlCache(); } } - return v8.deserialize(HelmChartManager.#cache.get(this.repo.name).data); + return JSON.parse(cacheEntry.data); } } diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index aac0e8aaca..c537a31aab 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -9,6 +9,9 @@ import * as yaml from "js-yaml"; import { toCamelCase } from "../../common/utils/camelCase"; import { execFile } from "child_process"; import { execHelm } from "./exec"; +import assert from "assert"; +import type { JsonObject, JsonValue } from "type-fest"; +import { isObject, json } from "../../common/utils"; export async function listReleases(pathToKubeconfig: string, namespace?: string): Promise[]> { const args = [ @@ -25,17 +28,17 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string) args.push("--kubeconfig", pathToKubeconfig); - const output = JSON.parse(await execHelm(args)); + const output = json.parse(await execHelm(args)); if (!Array.isArray(output) || output.length == 0) { return []; } - return output.map(toCamelCase); + return output.filter(isObject).map(toCamelCase); } -export async function installChart(chart: string, values: any, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) { +export async function installChart(chart: string, values: JsonValue, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) { const valuesFilePath = tempy.file({ name: "values.yaml" }); await fse.writeFile(valuesFilePath, yaml.dump(values)); @@ -110,10 +113,14 @@ export async function getRelease(name: string, namespace: string, kubeconfigPath "--output", "json", ]; - const release = JSON.parse(await execHelm(args, { + const release = json.parse(await execHelm(args, { maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB })); + if (!isObject(release) || Array.isArray(release)) { + return undefined; + } + release.resources = await getResources(name, namespace, kubeconfigPath, kubectlPath); return release; @@ -155,7 +162,7 @@ export async function getValues(name: string, { namespace, all = false, kubeconf } export async function getHistory(name: string, namespace: string, kubeconfigPath: string) { - return JSON.parse(await execHelm([ + return json.parse(await execHelm([ "history", name, "--output", "json", @@ -193,7 +200,7 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str try { const helmOutput = await execHelm(helmArgs); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; const kubectl = execFile(kubectlPath, kubectlArgs); @@ -202,7 +209,9 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str .on("exit", (code, signal) => { if (typeof code === "number") { if (code === 0) { - resolve(JSON.parse(stdout).items); + const output = json.parse(stdout) as { items: JsonObject[] }; + + resolve(output.items); } else { reject(stderr); } @@ -212,10 +221,11 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str }) .on("error", reject); + assert(kubectl.stderr && kubectl.stdout && kubectl.stdin, "For some reason the IO streams are undefined"); + kubectl.stderr.on("data", output => stderr += output); kubectl.stdout.on("data", output => stdout += output); - kubectl.stdin.write(helmOutput); - kubectl.stdin.end(); + kubectl.stdin.end(helmOutput); }); } catch { return []; diff --git a/src/main/helm/helm-repo-manager.injectable.ts b/src/main/helm/helm-repo-manager.injectable.ts new file mode 100644 index 0000000000..9a12a3ca71 --- /dev/null +++ b/src/main/helm/helm-repo-manager.injectable.ts @@ -0,0 +1,172 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +import yaml from "js-yaml"; +import { readFile } from "fs-extra"; +import { customRequestPromise } from "../../common/request"; +import orderBy from "lodash/orderBy"; +import logger from "../logger"; +import { execHelm } from "./exec"; +import type { HelmEnv, HelmRepo, HelmRepoConfig } from "./helm-repo-manager"; + +interface EnsuredHelmRepoManagerData { + helmEnv: HelmEnv; + didUpdateOnce: boolean; +} + +export class HelmRepoManager { + protected helmEnv?: HelmEnv; + protected didUpdateOnce?: boolean; + + public async loadAvailableRepos(): Promise { + const res = await customRequestPromise({ + uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json", + json: true, + resolveWithFullResponse: true, + timeout: 10000, + }); + + return orderBy(res.body as HelmRepo[], repo => repo.name); + } + + private async ensureInitialized(): Promise { + this.helmEnv ??= await this.parseHelmEnv(); + + const repos = await this.list(this.helmEnv); + + if (repos.length === 0) { + await this.addRepo({ + name: "bitnami", + url: "https://charts.bitnami.com/bitnami", + }); + } + + if (!this.didUpdateOnce) { + await this.update(); + this.didUpdateOnce = true; + } + + return { + didUpdateOnce: this.didUpdateOnce, + helmEnv: this.helmEnv, + }; + } + + protected async parseHelmEnv() { + const output = await execHelm(["env"]); + const lines = output.split(/\r?\n/); // split by new line feed + const env: Partial> = {}; + + lines.forEach((line: string) => { + const [key, value] = line.split("="); + + if (key && value) { + env[key] = value.replace(/"/g, ""); // strip quotas + } + }); + + return env as HelmEnv; + } + + public async repo(name: string): Promise { + const repos = await this.repositories(); + + return repos.find(repo => repo.name === name); + } + + private async list(helmEnv: HelmEnv): Promise { + try { + const rawConfig = await readFile(helmEnv.HELM_REPOSITORY_CONFIG, "utf8"); + const parsedConfig = yaml.load(rawConfig) as HelmRepoConfig; + + if (typeof parsedConfig === "object" && parsedConfig) { + return parsedConfig.repositories; + } + } catch { + // ignore error + } + + return []; + } + + public async repositories(): Promise { + try { + const { helmEnv } = await this.ensureInitialized(); + + const repos = await this.list(helmEnv); + + return repos.map(repo => ({ + ...repo, + cacheFilePath: `${helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`, + })); + } catch (error) { + logger.error(`[HELM]: repositories listing error`, error); + + return []; + } + } + + public async update() { + return execHelm([ + "repo", + "update", + ]); + } + + public async addRepo({ name, url, insecureSkipTlsVerify, username, password, caFile, keyFile, certFile }: HelmRepo) { + logger.info(`[HELM]: adding repo ${name} from ${url}`); + const args = [ + "repo", + "add", + name, + url, + ]; + + if (insecureSkipTlsVerify) { + args.push("--insecure-skip-tls-verify"); + } + + if (username) { + args.push("--username", username); + } + + if (password) { + args.push("--password", password); + } + + if (caFile) { + args.push("--ca-file", caFile); + } + + if (keyFile) { + args.push("--key-file", keyFile); + } + + if (certFile) { + args.push("--cert-file", certFile); + } + + return execHelm(args); + } + + public async removeRepo({ name, url }: HelmRepo): Promise { + logger.info(`[HELM]: removing repo ${name} (${url})`); + + return execHelm([ + "repo", + "remove", + name, + ]); + } +} + +const helmRepoManagerInjectable = getInjectable({ + id: "helm-repo-manager", + + instantiate: () => new HelmRepoManager(), +}); + +export default helmRepoManagerInjectable; diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index 7f174cfa8e..5726bafe16 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -3,17 +3,15 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import yaml from "js-yaml"; -import { readFile } from "fs-extra"; -import { Singleton } from "../../common/utils/singleton"; -import { customRequestPromise } from "../../common/request"; -import orderBy from "lodash/orderBy"; -import logger from "../logger"; -import { execHelm } from "./exec"; +import { + asLegacyGlobalSingletonForExtensionApi, +} from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api"; -export type HelmEnv = Record & { - HELM_REPOSITORY_CACHE?: string; - HELM_REPOSITORY_CONFIG?: string; +import helmRepoManagerInjectable from "./helm-repo-manager.injectable"; + +export type HelmEnv = Partial> & { + HELM_REPOSITORY_CACHE: string; + HELM_REPOSITORY_CONFIG: string; }; export interface HelmRepoConfig { @@ -32,144 +30,4 @@ export interface HelmRepo { password?: string; } -export class HelmRepoManager extends Singleton { - protected repos: HelmRepo[]; - protected helmEnv: HelmEnv; - protected didUpdateOnce: boolean; - - public async loadAvailableRepos(): Promise { - const res = await customRequestPromise({ - uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json", - json: true, - resolveWithFullResponse: true, - timeout: 10000, - }); - - return orderBy(res.body as HelmRepo[], repo => repo.name); - } - - private async ensureInitialized() { - this.helmEnv ??= await this.parseHelmEnv(); - - const repos = await this.list(); - - if (repos.length === 0) { - await this.addRepo({ - name: "bitnami", - url: "https://charts.bitnami.com/bitnami", - }); - } - - if (!this.didUpdateOnce) { - await this.update(); - this.didUpdateOnce = true; - } - } - - protected async parseHelmEnv() { - const output = await execHelm(["env"]); - const lines = output.split(/\r?\n/); // split by new line feed - const env: HelmEnv = {}; - - lines.forEach((line: string) => { - const [key, value] = line.split("="); - - if (key && value) { - env[key] = value.replace(/"/g, ""); // strip quotas - } - }); - - return env; - } - - public async repo(name: string): Promise { - const repos = await this.repositories(); - - return repos.find(repo => repo.name === name); - } - - private async list(): Promise { - try { - const rawConfig = await readFile(this.helmEnv.HELM_REPOSITORY_CONFIG, "utf8"); - const parsedConfig = yaml.load(rawConfig) as HelmRepoConfig; - - if (typeof parsedConfig === "object" && parsedConfig) { - return parsedConfig.repositories; - } - } catch { - // ignore error - } - - return []; - } - - public async repositories(): Promise { - try { - await this.ensureInitialized(); - - const repos = await this.list(); - - return repos.map(repo => ({ - ...repo, - cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`, - })); - } catch (error) { - logger.error(`[HELM]: repositories listing error`, error); - - return []; - } - } - - public async update() { - return execHelm([ - "repo", - "update", - ]); - } - - public async addRepo({ name, url, insecureSkipTlsVerify, username, password, caFile, keyFile, certFile }: HelmRepo) { - logger.info(`[HELM]: adding repo ${name} from ${url}`); - const args = [ - "repo", - "add", - name, - url, - ]; - - if (insecureSkipTlsVerify) { - args.push("--insecure-skip-tls-verify"); - } - - if (username) { - args.push("--username", username); - } - - if (password) { - args.push("--password", password); - } - - if (caFile) { - args.push("--ca-file", caFile); - } - - if (keyFile) { - args.push("--key-file", keyFile); - } - - if (certFile) { - args.push("--cert-file", certFile); - } - - return execHelm(args); - } - - public async removeRepo({ name, url }: HelmRepo): Promise { - logger.info(`[HELM]: removing repo ${name} (${url})`); - - return execHelm([ - "repo", - "remove", - name, - ]); - } -} +export const HelmRepoManager = asLegacyGlobalSingletonForExtensionApi(helmRepoManagerInjectable); diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index d3a17d3f63..adc9ea30a5 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -8,6 +8,8 @@ import logger from "../logger"; import { HelmRepoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager"; +import type { JsonObject } from "type-fest"; +import { object } from "../../common/utils"; interface GetReleaseValuesArgs { cluster: Cluster; @@ -15,8 +17,22 @@ interface GetReleaseValuesArgs { all: boolean; } +export interface InstallChartArgs { + chart: string; + values: JsonObject; + name: string; + namespace: string; + version: string; +} + +export interface UpdateChartArgs { + chart: string; + values: JsonObject; + version: string; +} + class HelmService { - public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { + public async installChart(cluster: Cluster, data: InstallChartArgs) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); return installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig); @@ -25,13 +41,18 @@ class HelmService { public async listCharts() { const repositories = await HelmRepoManager.getInstance().repositories(); - return Object.fromEntries( - await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()])), + return object.fromEntries( + await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()] as const)), ); } public async getChart(repoName: string, chartName: string, version = "") { const repo = await HelmRepoManager.getInstance().repo(repoName); + + if (!repo) { + return undefined; + } + const chartManager = HelmChartManager.forRepo(repo); return { @@ -43,10 +64,14 @@ class HelmService { public async getChartValues(repoName: string, chartName: string, version = "") { const repo = await HelmRepoManager.getInstance().repo(repoName); + if (!repo) { + return undefined; + } + return HelmChartManager.forRepo(repo).getValues(chartName, version); } - public async listReleases(cluster: Cluster, namespace: string = null) { + public async listReleases(cluster: Cluster, namespace?: string) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); logger.debug("list releases"); @@ -88,7 +113,7 @@ class HelmService { return deleteRelease(releaseName, namespace, proxyKubeconfig); } - public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { + public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: UpdateChartArgs) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); const kubectl = await cluster.ensureKubectl(); const kubectlPath = await kubectl.getPath(); diff --git a/src/main/index.ts b/src/main/index.ts index f6c0b1bbc0..3752c5a8c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,362 +5,19 @@ // Main process -import { injectSystemCAs } from "../common/system-ca"; import * as Mobx from "mobx"; -import httpProxy from "http-proxy"; import * as LensExtensionsCommonApi from "../extensions/common-api"; import * as LensExtensionsMainApi from "../extensions/main-api"; -import { app, autoUpdater, dialog, powerMonitor } from "electron"; -import { appName, isIntegrationTesting, isMac, isWindows, productName, staticFilesDirectory } from "../common/vars"; -import { LensProxy } from "./lens-proxy"; -import { WindowManager } from "./window-manager"; -import { ClusterManager } from "./cluster-manager"; -import { shellSync } from "./shell-sync"; -import { mangleProxyEnv } from "./proxy-env"; -import { registerFileProtocol } from "../common/register-protocol"; -import logger from "./logger"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import type { InstalledExtension } from "../extensions/extension-discovery/extension-discovery"; -import type { LensExtensionId } from "../extensions/lens-extension"; -import { installDeveloperTools } from "./developer-tools"; -import { disposer, getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; -import { ipcMainOn } from "../common/ipc"; -import { startUpdateChecking } from "./app-updater"; -import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; -import { startCatalogSyncToRenderer } from "./catalog-pusher"; -import { catalogEntityRegistry } from "./catalog"; -import { HelmRepoManager } from "./helm/helm-repo-manager"; -import { syncWeblinks } from "./catalog-sources"; -import configurePackages from "../common/configure-packages"; -import { PrometheusProviderRegistry } from "./prometheus"; -import * as initializers from "./initializers"; -import { WeblinkStore } from "../common/weblink-store"; -import { initializeSentryReporting } from "../common/sentry"; -import { ensureDir } from "fs-extra"; -import { initMenu } from "./menu/menu"; -import { kubeApiUpgradeRequest } from "./proxy-functions"; -import { initTray } from "./tray/tray"; -import { ShellSession } from "./shell-session/shell-session"; import { getDi } from "./getDi"; -import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; -import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; -import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable"; -import directoryForExesInjectable from "../common/app-paths/directory-for-exes/directory-for-exes.injectable"; -import initIpcMainHandlersInjectable from "./initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable"; -import directoryForKubeConfigsInjectable from "../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; -import kubeconfigSyncManagerInjectable from "./catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable"; -import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; -import routerInjectable from "./router/router.injectable"; -import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable"; -import userStoreInjectable from "../common/user-store/user-store.injectable"; -import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable"; -import { broadcastNativeThemeOnUpdate } from "./native-theme"; -import windowManagerInjectable from "./window-manager.injectable"; -import navigateToPreferencesInjectable from "../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; -import syncGeneralCatalogEntitiesInjectable from "./catalog-sources/sync-general-catalog-entities.injectable"; -import hotbarStoreInjectable from "../common/hotbar-store.injectable"; -import applicationMenuItemsInjectable from "./menu/application-menu-items.injectable"; -import type { DiContainer } from "@ogre-tools/injectable"; -import { init } from "@sentry/electron/main"; +import startMainApplicationInjectable from "./start-main-application/start-main-application.injectable"; -async function main(di: DiContainer) { - app.setName(appName); +const di = getDi(); - /** - * Note: this MUST be called before electron's "ready" event has been emitted. - */ - initializeSentryReporting(init); - await di.runSetups(); - await app.whenReady(); +const startApplication = di.inject(startMainApplicationInjectable); - injectSystemCAs(); - - const onCloseCleanup = disposer(); - const onQuitCleanup = disposer(); - - logger.info(`📟 Setting ${productName} as protocol client for lens://`); - - if (app.setAsDefaultProtocolClient("lens")) { - logger.info("📟 Protocol client register succeeded ✅"); - } else { - logger.info("📟 Protocol client register failed ❗"); - } - - if (process.env.LENS_DISABLE_GPU) { - app.disableHardwareAcceleration(); - } - - logger.debug("[APP-MAIN] configuring packages"); - configurePackages(); - - mangleProxyEnv(); - - const initIpcMainHandlers = di.inject(initIpcMainHandlersInjectable); - - logger.debug("[APP-MAIN] initializing ipc main handlers"); - initIpcMainHandlers(); - - if (app.commandLine.getSwitchValue("proxy-server") !== "") { - process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); - } - - logger.debug("[APP-MAIN] Lens protocol routing main"); - - const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); - - if (!app.requestSingleInstanceLock()) { - app.exit(); - } else { - for (const arg of process.argv) { - if (arg.toLowerCase().startsWith("lens://")) { - lensProtocolRouterMain.route(arg); - } - } - } - - broadcastNativeThemeOnUpdate(); - - app.on("second-instance", (event, argv) => { - logger.debug("second-instance message"); - - for (const arg of argv) { - if (arg.toLowerCase().startsWith("lens://")) { - lensProtocolRouterMain.route(arg); - } - } - - WindowManager.getInstance(false)?.ensureMainWindow(); - }); - - app.on("activate", (event, hasVisibleWindows) => { - logger.info("APP:ACTIVATE", { hasVisibleWindows }); - - if (!hasVisibleWindows) { - WindowManager.getInstance(false)?.ensureMainWindow(false); - } - }); - - /** - * This variable should is used so that `autoUpdater.installAndQuit()` works - */ - let blockQuit = !isIntegrationTesting; - - autoUpdater.on("before-quit-for-update", () => { - logger.debug("Unblocking quit for update"); - blockQuit = false; - }); - - app.on("will-quit", (event) => { - logger.debug("will-quit message"); - - // This is called when the close button of the main window is clicked - - - logger.info("APP:QUIT"); - appEventBus.emit({ name: "app", action: "close" }); - ClusterManager.getInstance(false)?.stop(); // close cluster connections - - const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); - - kubeConfigSyncManager.stopSync(); - - onCloseCleanup(); - - // This is set to false here so that LPRM can wait to send future lens:// - // requests until after it loads again - lensProtocolRouterMain.rendererLoaded = false; - - if (blockQuit) { - // Quit app on Cmd+Q (MacOS) - - event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) - - return; // skip exit to make tray work, to quit go to app's global menu or tray's menu - } - - lensProtocolRouterMain.cleanup(); - onQuitCleanup(); - }); - - app.on("open-url", (event, rawUrl) => { - logger.debug("open-url message"); - - // lens:// protocol handler - event.preventDefault(); - lensProtocolRouterMain.route(rawUrl); - }); - - const directoryForExes = di.inject(directoryForExesInjectable); - - logger.info(`🚀 Starting ${productName} from "${directoryForExes}"`); - logger.info("🐚 Syncing shell environment"); - await shellSync(); - - powerMonitor.on("shutdown", () => app.exit()); - - registerFileProtocol("static", staticFilesDirectory); - - PrometheusProviderRegistry.createInstance(); - initializers.initPrometheusProviderRegistry(); - - /** - * The following sync MUST be done before HotbarStore creation, because that - * store has migrations that will remove items that previous migrations add - * if this is not present - */ - const syncGeneralCatalogEntities = di.inject(syncGeneralCatalogEntitiesInjectable); - - syncGeneralCatalogEntities(); - - logger.info("💾 Loading stores"); - - const userStore = di.inject(userStoreInjectable); - - userStore.startMainReactions(); - - // ClusterStore depends on: UserStore - const clusterStore = di.inject(clusterStoreInjectable); - - clusterStore.provideInitialFromMain(); - - // HotbarStore depends on: ClusterStore - di.inject(hotbarStoreInjectable); - - WeblinkStore.createInstance(); - - syncWeblinks(); - - HelmRepoManager.createInstance(); // create the instance - - const router = di.inject(routerInjectable); - const shellApiRequest = di.inject(shellApiRequestInjectable); - - const lensProxy = LensProxy.createInstance(router, httpProxy.createProxy(), { - getClusterForRequest: (req) => ClusterManager.getInstance().getClusterForRequest(req), - kubeApiUpgradeRequest, - shellApiRequest, - }); - - ClusterManager.createInstance().init(); - - initializers.initClusterMetadataDetectors(); - - try { - logger.info("🔌 Starting LensProxy"); - await lensProxy.listen(); // lensProxy.port available - } catch (error) { - dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); - - return app.exit(); - } - - // test proxy connection - try { - logger.info("🔎 Testing LensProxy connection ..."); - const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); - - if (getAppVersion() !== versionFromProxy) { - logger.error("Proxy server responded with invalid response"); - - return app.exit(); - } - - logger.info("⚡ LensProxy connection OK"); - } catch (error) { - logger.error(`🛑 LensProxy: failed connection test: ${error}`); - - const hostsPath = isWindows - ? "C:\\windows\\system32\\drivers\\etc\\hosts" - : "/etc/hosts"; - const message = [ - `Failed connection test: ${error}`, - "Check to make sure that no other versions of Lens are running", - `Check ${hostsPath} to make sure that it is clean and that the localhost loopback is at the top and set to 127.0.0.1`, - "If you have HTTP_PROXY or http_proxy set in your environment, make sure that the localhost and the ipv4 loopback address 127.0.0.1 are added to the NO_PROXY environment variable.", - ]; - - dialog.showErrorBox("Lens Proxy Error", message.join("\n\n")); - - return app.exit(); - } - - const extensionLoader = di.inject(extensionLoaderInjectable); - - extensionLoader.init(); - - const extensionDiscovery = di.inject(extensionDiscoveryInjectable); - - extensionDiscovery.init(); - - // Start the app without showing the main window when auto starting on login - // (On Windows and Linux, we get a flag. On MacOS, we get special API.) - const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden); - - logger.info("🖥️ Starting WindowManager"); - const windowManager = di.inject(windowManagerInjectable); - - const applicationMenuItems = di.inject(applicationMenuItemsInjectable); - const trayMenuItems = di.inject(trayMenuItemsInjectable); - const navigateToPreferences = di.inject(navigateToPreferencesInjectable); - - onQuitCleanup.push( - initMenu(applicationMenuItems), - initTray(windowManager, trayMenuItems, navigateToPreferences), - () => ShellSession.cleanup(), - ); - - installDeveloperTools(); - - if (!startHidden) { - windowManager.ensureMainWindow(); - } - - ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { - onCloseCleanup.push(startCatalogSyncToRenderer(catalogEntityRegistry)); - - const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); - - await ensureDir(directoryForKubeConfigs); - - const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); - - kubeConfigSyncManager.startSync(); - - startUpdateChecking(); - lensProtocolRouterMain.rendererLoaded = true; - }); - - logger.info("🧩 Initializing extensions"); - - // call after windowManager to see splash earlier - try { - const extensions = await extensionDiscovery.load(); - - // Start watching after bundled extensions are loaded - extensionDiscovery.watchExtensions(); - - // Subscribe to extensions that are copied or deleted to/from the extensions folder - extensionDiscovery.events - .on("add", (extension: InstalledExtension) => { - extensionLoader.addExtension(extension); - }) - .on("remove", (lensExtensionId: LensExtensionId) => { - extensionLoader.removeExtension(lensExtensionId); - }); - - extensionLoader.initExtensions(extensions); - } catch (error) { - dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); - console.error(error); - console.trace(); - } - - setTimeout(() => { - appEventBus.emit({ name: "service", action: "start" }); - }, 1000); -} - -main(getDi()); +(async () => { + await startApplication(); +})(); /** * Exports for virtual package "@k8slens/extensions" for main-process. @@ -372,7 +29,4 @@ const LensExtensions = { Main: LensExtensionsMainApi, }; -export { - Mobx, - LensExtensions, -}; +export { Mobx, LensExtensions }; diff --git a/src/main/initializers/cluster-metadata-detectors.ts b/src/main/initializers/cluster-metadata-detectors.ts deleted file mode 100644 index 95d724bf3f..0000000000 --- a/src/main/initializers/cluster-metadata-detectors.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { ClusterIdDetector } from "../cluster-detectors/cluster-id-detector"; -import { DetectorRegistry } from "../cluster-detectors/detector-registry"; -import { DistributionDetector } from "../cluster-detectors/distribution-detector"; -import { LastSeenDetector } from "../cluster-detectors/last-seen-detector"; -import { NodesCountDetector } from "../cluster-detectors/nodes-count-detector"; -import { VersionDetector } from "../cluster-detectors/version-detector"; - -export function initClusterMetadataDetectors() { - DetectorRegistry.createInstance() - .add(ClusterIdDetector) - .add(LastSeenDetector) - .add(VersionDetector) - .add(DistributionDetector) - .add(NodesCountDetector); -} diff --git a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts deleted file mode 100644 index d92897bb50..0000000000 --- a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; -import { initIpcMainHandlers } from "./init-ipc-main-handlers"; -import getAbsolutePathInjectable from "../../../common/path/get-absolute-path.injectable"; -import applicationMenuItemsInjectable from "../../menu/application-menu-items.injectable"; - -const initIpcMainHandlersInjectable = getInjectable({ - id: "init-ipc-main-handlers", - - instantiate: (di) => initIpcMainHandlers({ - applicationMenuItems: di.inject(applicationMenuItemsInjectable), - directoryForLensLocalStorage: di.inject(directoryForLensLocalStorageInjectable), - getAbsolutePath: di.inject(getAbsolutePathInjectable), - }), -}); - -export default initIpcMainHandlersInjectable; diff --git a/src/main/initializers/metrics-providers.ts b/src/main/initializers/metrics-providers.ts deleted file mode 100644 index b8e8208a29..0000000000 --- a/src/main/initializers/metrics-providers.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { PrometheusProviderRegistry } from "../prometheus"; -import { PrometheusHelm } from "../prometheus/helm"; -import { PrometheusLens } from "../prometheus/lens"; -import { PrometheusOperator } from "../prometheus/operator"; -import { PrometheusStacklight } from "../prometheus/stacklight"; - -export function initPrometheusProviderRegistry() { - PrometheusProviderRegistry - .getInstance() - .registerProvider(new PrometheusLens()) - .registerProvider(new PrometheusHelm()) - .registerProvider(new PrometheusOperator()) - .registerProvider(new PrometheusStacklight()); -} diff --git a/src/main/ipc/ask-user-for-file-paths.injectable.ts b/src/main/ipc/ask-user-for-file-paths.injectable.ts new file mode 100644 index 0000000000..9fc1ac1471 --- /dev/null +++ b/src/main/ipc/ask-user-for-file-paths.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { OpenDialogOptions } from "electron"; +import { dialog } from "electron"; +import { getInjectable } from "@ogre-tools/injectable"; +import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; + +// TODO: Replace leaking electron with abstraction +export type AskUserForFilePaths = ( + dialogOptions: OpenDialogOptions +) => Promise<{ canceled: boolean; filePaths: string[] }>; + +const askUserForFilePathsInjectable = getInjectable({ + id: "ask-user-for-file-paths", + + instantiate: (di): AskUserForFilePaths => { + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + + return async (dialogOptions) => { + await showApplicationWindow(); + + const { canceled, filePaths } = await dialog.showOpenDialog( + dialogOptions, + ); + + return { canceled, filePaths }; + }; + }, + + causesSideEffects: true, +}); + +export default askUserForFilePathsInjectable; diff --git a/src/main/ipc/dialog.ts b/src/main/ipc/dialog.ts deleted file mode 100644 index f1630db85f..0000000000 --- a/src/main/ipc/dialog.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { OpenDialogOptions } from "electron"; -import { BrowserWindow, dialog } from "electron"; - -export async function showOpenDialog(dialogOptions: OpenDialogOptions): Promise<{ canceled: boolean; filePaths: string[] }> { - const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOptions); - - return { canceled, filePaths }; -} diff --git a/src/main/is-auto-update-enabled.injectable.ts b/src/main/is-auto-update-enabled.injectable.ts deleted file mode 100644 index 4a61318e46..0000000000 --- a/src/main/is-auto-update-enabled.injectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { isAutoUpdateEnabled } from "./app-updater"; - -const isAutoUpdateEnabledInjectable = getInjectable({ - id: "is-auto-update-enabled", - instantiate: () => isAutoUpdateEnabled, - causesSideEffects: true, -}); - -export default isAutoUpdateEnabledInjectable; diff --git a/src/main/k8s-request.injectable.ts b/src/main/k8s-request.injectable.ts new file mode 100644 index 0000000000..a556cf62f2 --- /dev/null +++ b/src/main/k8s-request.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RequestPromiseOptions } from "request-promise-native"; +import request from "request-promise-native"; +import { apiKubePrefix } from "../common/vars"; +import type { Cluster } from "../common/cluster/cluster"; +import { getInjectable } from "@ogre-tools/injectable"; +import lensProxyPortInjectable from "./lens-proxy/lens-proxy-port.injectable"; + +export type K8sRequest = (cluster: Cluster, path: string, options?: RequestPromiseOptions) => Promise; + +const k8SRequestInjectable = getInjectable({ + id: "k8s-request", + + instantiate: (di) => { + const lensProxyPort = di.inject(lensProxyPortInjectable); + + return async ( + cluster: Cluster, + path: string, + options: RequestPromiseOptions = {}, + ) => { + const kubeProxyUrl = `http://localhost:${lensProxyPort.get()}${apiKubePrefix}`; + + options.headers ??= {}; + options.json ??= true; + options.timeout ??= 30000; + options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest() + + return request(kubeProxyUrl + path, options); + }; + }, +}); + +export default k8SRequestInjectable; diff --git a/src/main/k8s-request.ts b/src/main/k8s-request.ts deleted file mode 100644 index 8531e9abb7..0000000000 --- a/src/main/k8s-request.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { RequestPromiseOptions } from "request-promise-native"; -import request from "request-promise-native"; -import { apiKubePrefix } from "../common/vars"; -import type { IMetricsReqParams } from "../common/k8s-api/endpoints/metrics.api"; -import { LensProxy } from "./lens-proxy"; -import type { Cluster } from "../common/cluster/cluster"; - -export async function k8sRequest(cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise { - const kubeProxyUrl = `http://localhost:${LensProxy.getInstance().port}${apiKubePrefix}`; - - options.headers ??= {}; - options.json ??= true; - options.timeout ??= 30000; - options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest() - - return request(kubeProxyUrl + path, options); -} - -export async function getMetrics(cluster: Cluster, prometheusPath: string, queryParams: IMetricsReqParams & { query: string }): Promise { - const prometheusPrefix = cluster.preferences.prometheus?.prefix || ""; - const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; - - return k8sRequest(cluster, metricsPath, { - timeout: 0, - resolveWithFullResponse: false, - json: true, - method: "POST", - form: queryParams, - }); -} diff --git a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts index 9d62a04cef..07bb87bd63 100644 --- a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -9,9 +9,10 @@ import type { Cluster } from "../../common/cluster/cluster"; import path from "path"; import selfsigned from "selfsigned"; import { getBinaryName } from "../../common/vars"; -import directoryForBundledBinariesInjectable from "../../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; import spawnInjectable from "../child-process/spawn.injectable"; import { getKubeAuthProxyCertificate } from "./get-kube-auth-proxy-certificate"; +import loggerInjectable from "../../common/logger.injectable"; +import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable"; export type CreateKubeAuthProxy = (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy; @@ -24,9 +25,10 @@ const createKubeAuthProxyInjectable = getInjectable({ return (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => { const clusterUrl = new URL(cluster.apiUrl); const dependencies: KubeAuthProxyDependencies = { - proxyBinPath: path.join(di.inject(directoryForBundledBinariesInjectable), binaryName), + proxyBinPath: path.join(di.inject(baseBundeledBinariesDirectoryInjectable), binaryName), proxyCert: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate), spawn: di.inject(spawnInjectable), + logger: di.inject(loggerInjectable), }; return new KubeAuthProxy(dependencies, cluster, environmentVariables); diff --git a/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts b/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts index fb4c13da60..f6fa71702c 100644 --- a/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts +++ b/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts @@ -4,36 +4,32 @@ */ import type * as selfsigned from "selfsigned"; +import { getOrInsertWith } from "../../common/utils"; type SelfSignedGenerate = typeof selfsigned.generate; -const certCache: Map = new Map(); +const certCache = new Map(); -export function getKubeAuthProxyCertificate(hostname: string, generate: SelfSignedGenerate, useCache = true): selfsigned.SelfSignedCert { - if (useCache && certCache.has(hostname)) { - return certCache.get(hostname); - } - - const opts = [ - { name: "commonName", value: "Lens Certificate Authority" }, - { name: "organizationName", value: "Lens" }, - ]; - - const cert = generate(opts, { - keySize: 2048, - algorithm: "sha256", - days: 365, - extensions: [ - { name: "basicConstraints", cA: true }, - { name: "subjectAltName", altNames: [ - { type: 2, value: hostname }, - { type: 2, value: "localhost" }, - { type: 7, ip: "127.0.0.1" }, - ] }, +export function getKubeAuthProxyCertificate(hostname: string, generate: SelfSignedGenerate): selfsigned.SelfSignedCert { + return getOrInsertWith(certCache, hostname, () => generate( + [ + { name: "commonName", value: "Lens Certificate Authority" }, + { name: "organizationName", value: "Lens" }, ], - }); - - certCache.set(hostname, cert); - - return cert; + { + keySize: 2048, + algorithm: "sha256", + days: 365, + extensions: [ + { name: "basicConstraints", cA: true }, + { + name: "subjectAltName", altNames: [ + { type: 2, value: hostname }, + { type: 2, value: "localhost" }, + { type: 7, ip: "127.0.0.1" }, + ], + }, + ], + }, + )); } diff --git a/src/main/kube-auth-proxy/kube-auth-proxy.ts b/src/main/kube-auth-proxy/kube-auth-proxy.ts index dfeff29c98..414ea334da 100644 --- a/src/main/kube-auth-proxy/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { ChildProcess, spawn } from "child_process"; +import type { ChildProcess } from "child_process"; import { waitUntilUsed } from "tcp-port-used"; import { randomBytes } from "crypto"; import type { Cluster } from "../../common/cluster/cluster"; @@ -11,27 +11,36 @@ import logger from "../logger"; import { getPortFrom } from "../utils/get-port"; import { makeObservable, observable, when } from "mobx"; import type { SelfSignedCert } from "selfsigned"; +import assert from "assert"; +import { TypedRegEx } from "typed-regex"; +import type { Spawn } from "../child-process/spawn.injectable"; +import type { Logger } from "../../common/logger"; -const startingServeRegex = /starting to serve on (?
.+)/i; +const startingServeRegex = TypedRegEx("starting to serve on (?
.+)", "i"); export interface KubeAuthProxyDependencies { - proxyBinPath: string; - proxyCert: SelfSignedCert; - spawn: typeof spawn; + readonly proxyBinPath: string; + readonly proxyCert: SelfSignedCert; + spawn: Spawn; + readonly logger: Logger; } export class KubeAuthProxy { public readonly apiPrefix = `/${randomBytes(8).toString("hex")}`; public get port(): number { - return this._port; + const port = this._port; + + assert(port, "port has not yet been initialized"); + + return port; } - protected _port: number; + protected _port?: number; protected proxyProcess?: ChildProcess; @observable protected ready = false; - constructor(private dependencies: KubeAuthProxyDependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { + constructor(private readonly dependencies: KubeAuthProxyDependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { makeObservable(this); } @@ -63,7 +72,7 @@ export class KubeAuthProxy { }); this.proxyProcess.on("exit", (code) => { - this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0); + this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code ? code > 0: false); this.exit(); }); @@ -72,6 +81,9 @@ export class KubeAuthProxy { this.exit(); }); + assert(this.proxyProcess.stderr); + assert(this.proxyProcess.stdout); + this.proxyProcess.stderr.on("data", (data: Buffer) => { if (data.includes("http: TLS handshake error")) { return; @@ -91,10 +103,13 @@ export class KubeAuthProxy { onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"), }); + logger.info(`[KUBE-AUTH-PROXY]: found port=${this._port}`); + try { await waitUntilUsed(this.port, 500, 10000); this.ready = true; } catch (error) { + logger.warn("[KUBE-AUTH-PROXY]: waitUntilUsed failed", error); this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true); this.exit(); @@ -108,10 +123,10 @@ export class KubeAuthProxy { if (this.proxyProcess) { logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); this.proxyProcess.removeAllListeners(); - this.proxyProcess.stderr.removeAllListeners(); - this.proxyProcess.stdout.removeAllListeners(); + this.proxyProcess.stderr?.removeAllListeners(); + this.proxyProcess.stdout?.removeAllListeners(); this.proxyProcess.kill(); - this.proxyProcess = null; + this.proxyProcess = undefined; } } } diff --git a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts index fd75737555..e1f365f098 100644 --- a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts +++ b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts @@ -5,21 +5,28 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { Cluster } from "../../common/cluster/cluster"; import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import type { KubeconfigManagerDependencies } from "./kubeconfig-manager"; import { KubeconfigManager } from "./kubeconfig-manager"; +import loggerInjectable from "../../common/logger.injectable"; +import lensProxyPortInjectable from "../lens-proxy/lens-proxy-port.injectable"; export interface KubeConfigManagerInstantiationParameter { cluster: Cluster; } +export type CreateKubeconfigManager = (cluster: Cluster) => KubeconfigManager; + const createKubeconfigManagerInjectable = getInjectable({ id: "create-kubeconfig-manager", - instantiate: (di) => { - const dependencies = { + instantiate: (di): CreateKubeconfigManager => { + const dependencies: KubeconfigManagerDependencies = { directoryForTemp: di.inject(directoryForTempInjectable), + logger: di.inject(loggerInjectable), + lensProxyPort: di.inject(lensProxyPortInjectable), }; - return (cluster: Cluster) => new KubeconfigManager(dependencies, cluster); + return (cluster) => new KubeconfigManager(dependencies, cluster); }, }); diff --git a/src/main/kubeconfig-manager/kubeconfig-manager.ts b/src/main/kubeconfig-manager/kubeconfig-manager.ts index fb72b5ebac..d7a9794a0f 100644 --- a/src/main/kubeconfig-manager/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager/kubeconfig-manager.ts @@ -5,15 +5,18 @@ import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../../common/cluster/cluster"; -import type { ContextHandler } from "../context-handler/context-handler"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; import path from "path"; import fs from "fs-extra"; import { dumpConfigYaml } from "../../common/kube-helpers"; -import logger from "../logger"; -import { LensProxy } from "../lens-proxy"; +import { isErrnoException } from "../../common/utils"; +import type { PartialDeep } from "type-fest"; +import type { Logger } from "../../common/logger"; -interface Dependencies { - directoryForTemp: string; +export interface KubeconfigManagerDependencies { + readonly directoryForTemp: string; + readonly logger: Logger; + lensProxyPort: { get: () => number }; } export class KubeconfigManager { @@ -26,9 +29,9 @@ export class KubeconfigManager { */ protected tempFilePath: string | null | undefined = null; - protected contextHandler: ContextHandler; + protected readonly contextHandler: ClusterContextHandler; - constructor(private dependencies: Dependencies, protected cluster: Cluster) { + constructor(private readonly dependencies: KubeconfigManagerDependencies, protected cluster: Cluster) { this.contextHandler = cluster.contextHandler; } @@ -42,7 +45,7 @@ export class KubeconfigManager { } if (this.tempFilePath === null || !(await fs.pathExists(this.tempFilePath))) { - await this.ensureFile(); + return await this.ensureFile(); } return this.tempFilePath; @@ -56,12 +59,12 @@ export class KubeconfigManager { return; } - logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFilePath}`); + this.dependencies.logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFilePath}`); try { await fs.unlink(this.tempFilePath); } catch (error) { - if (error.code !== "ENOENT") { + if (isErrnoException(error) && error.code !== "ENOENT") { throw error; } } finally { @@ -72,14 +75,15 @@ export class KubeconfigManager { protected async ensureFile() { try { await this.contextHandler.ensureServer(); - this.tempFilePath = await this.createProxyKubeconfig(); + + return this.tempFilePath = await this.createProxyKubeconfig(); } catch (error) { throw Object.assign(new Error("Failed to creat temp config for auth-proxy"), { cause: error }); } } get resolveProxyUrl() { - return `http://127.0.0.1:${LensProxy.getInstance().port}/${this.cluster.id}`; + return `http://127.0.0.1:${this.dependencies.lensProxyPort.get()}/${this.cluster.id}`; } /** @@ -94,13 +98,12 @@ export class KubeconfigManager { `kubeconfig-${id}`, ); const kubeConfig = await cluster.getKubeconfig(); - const proxyConfig: Partial = { + const proxyConfig: PartialDeep = { currentContext: contextName, clusters: [ { name: contextName, server: this.resolveProxyUrl, - skipTLSVerify: undefined, }, ], users: [ @@ -111,7 +114,7 @@ export class KubeconfigManager { user: "proxy", name: contextName, cluster: contextName, - namespace: cluster.defaultNamespace || kubeConfig.getContextObject(contextName).namespace, + namespace: cluster.defaultNamespace || kubeConfig.getContextObject(contextName)?.namespace, }, ], }; @@ -120,7 +123,7 @@ export class KubeconfigManager { await fs.ensureDir(path.dirname(tempFile)); await fs.writeFile(tempFile, configYaml, { mode: 0o600 }); - logger.debug(`[KUBECONFIG-MANAGER]: Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); + this.dependencies.logger.debug(`[KUBECONFIG-MANAGER]: Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); return tempFile; } diff --git a/src/main/kubectl/binary-name.injectable.ts b/src/main/kubectl/binary-name.injectable.ts new file mode 100644 index 0000000000..66b42a6007 --- /dev/null +++ b/src/main/kubectl/binary-name.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; + +const kubectlBinaryNameInjectable = getInjectable({ + id: "kubectl-binary-name", + instantiate: (di) => { + const platform = di.inject(normalizedPlatformInjectable); + + return platform === "windows" + ? "kubectl.exe" + : "kubectl"; + }, +}); + +export default kubectlBinaryNameInjectable; diff --git a/src/main/kubectl/bundled-binary-path.injectable.ts b/src/main/kubectl/bundled-binary-path.injectable.ts new file mode 100644 index 0000000000..99cb7f2e7e --- /dev/null +++ b/src/main/kubectl/bundled-binary-path.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; +import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable"; +import kubectlBinaryNameInjectable from "./binary-name.injectable"; + +const bundledKubectlBinaryPathInjectable = getInjectable({ + id: "bundled-kubectl-binary-path", + instantiate: (di) => path.join( + di.inject(baseBundeledBinariesDirectoryInjectable), + di.inject(kubectlBinaryNameInjectable), + ), +}); + +export default bundledKubectlBinaryPathInjectable; diff --git a/src/main/kubectl/create-kubectl.injectable.ts b/src/main/kubectl/create-kubectl.injectable.ts index 931f777535..d670e8e665 100644 --- a/src/main/kubectl/create-kubectl.injectable.ts +++ b/src/main/kubectl/create-kubectl.injectable.ts @@ -3,24 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import type { KubectlDependencies } from "./kubectl"; import { Kubectl } from "./kubectl"; import directoryForKubectlBinariesInjectable from "../../common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable"; import userStoreInjectable from "../../common/user-store/user-store.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "./normalized-arch.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "./binary-name.injectable"; +import bundledKubectlBinaryPathInjectable from "./bundled-binary-path.injectable"; +import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable"; const createKubectlInjectable = getInjectable({ id: "create-kubectl", instantiate: (di) => { - const dependencies = { + const dependencies: KubectlDependencies = { userStore: di.inject(userStoreInjectable), - - directoryForKubectlBinaries: di.inject( - directoryForKubectlBinariesInjectable, - ), + directoryForKubectlBinaries: di.inject(directoryForKubectlBinariesInjectable), + normalizedDownloadArch: di.inject(kubectlDownloadingNormalizedArchInjectable), + normalizedDownloadPlatform: di.inject(normalizedPlatformInjectable), + kubectlBinaryName: di.inject(kubectlBinaryNameInjectable), + bundledKubectlBinaryPath: di.inject(bundledKubectlBinaryPathInjectable), + baseBundeledBinariesDirectory: di.inject(baseBundeledBinariesDirectoryInjectable), }; - return (clusterVersion: string) => - new Kubectl(dependencies, clusterVersion); + return (clusterVersion: string) => new Kubectl(dependencies, clusterVersion); }, }); diff --git a/src/main/kubectl/kubectl.ts b/src/main/kubectl/kubectl.ts index 690b9970b9..55f7e4675d 100644 --- a/src/main/kubectl/kubectl.ts +++ b/src/main/kubectl/kubectl.ts @@ -10,7 +10,6 @@ import logger from "../logger"; import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; import { getBundledKubectlVersion } from "../../common/utils/app-version"; -import { normalizedPlatform, normalizedArch, kubectlBinaryName, kubectlBinaryPath, baseBinariesDir } from "../../common/vars"; import { SemVer } from "semver"; import { defaultPackageMirror, packageMirrors } from "../../common/user-store/preferences-helpers"; import got from "got/dist/source"; @@ -40,28 +39,31 @@ const kubectlMap: Map = new Map([ ]); const initScriptVersionString = "# lens-initscript v3"; -interface Dependencies { - directoryForKubectlBinaries: string; - - userStore: { - kubectlBinariesPath?: string; - downloadBinariesPath?: string; - downloadKubectlBinaries: boolean; - downloadMirror: string; +export interface KubectlDependencies { + readonly directoryForKubectlBinaries: string; + readonly normalizedDownloadPlatform: "darwin" | "linux" | "windows"; + readonly normalizedDownloadArch: "amd64" | "arm64" | "386"; + readonly kubectlBinaryName: string; + readonly bundledKubectlBinaryPath: string; + readonly baseBundeledBinariesDirectory: string; + readonly userStore: { + readonly kubectlBinariesPath?: string; + readonly downloadBinariesPath?: string; + readonly downloadKubectlBinaries: boolean; + readonly downloadMirror: string; }; } export class Kubectl { - public kubectlVersion: string; - protected directory: string; - protected url: string; - protected path: string; - protected dirname: string; + public readonly kubectlVersion: string; + protected readonly url: string; + protected readonly path: string; + protected readonly dirname: string; - public static readonly bundledKubectlVersion: string = bundledVersion; + public static readonly bundledKubectlVersion = bundledVersion; public static invalidBundle = false; - constructor(private dependencies: Dependencies, clusterVersion: string) { + constructor(protected readonly dependencies: KubectlDependencies, clusterVersion: string) { let version: SemVer; try { @@ -70,25 +72,27 @@ export class Kubectl { version = new SemVer(Kubectl.bundledKubectlVersion); } - const minorVersion = `${version.major}.${version.minor}`; + const fromMajorMinor = kubectlMap.get(`${version.major}.${version.minor}`); - /* minorVersion is the first two digits of kube server version - if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ - if (kubectlMap.has(minorVersion)) { - this.kubectlVersion = kubectlMap.get(minorVersion); + /** + * minorVersion is the first two digits of kube server version if the version map includes that, + * use that version, if not, fallback to the exact x.y.z of kube version + */ + if (fromMajorMinor) { + this.kubectlVersion = fromMajorMinor; logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using version map`); } else { this.kubectlVersion = version.format(); logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using fallback`); } - this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${normalizedPlatform}/${normalizedArch}/${kubectlBinaryName}`; + this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${this.dependencies.normalizedDownloadPlatform}/${this.dependencies.normalizedDownloadArch}/${this.dependencies.kubectlBinaryName}`; this.dirname = path.normalize(path.join(this.getDownloadDir(), this.kubectlVersion)); - this.path = path.join(this.dirname, kubectlBinaryName); + this.path = path.join(this.dirname, this.dependencies.kubectlBinaryName); } public getBundledPath() { - return kubectlBinaryPath.get(); + return this.dependencies.bundledKubectlBinaryPath; } public getPathFromPreferences() { @@ -155,7 +159,7 @@ export class Kubectl { try { const args = [ "version", - "--client", "true", + "--client", "--output", "json", ]; const { stdout } = await promiseExecFile(path, args); @@ -176,8 +180,8 @@ export class Kubectl { return true; } logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`); - } catch (err) { - logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`); + } catch (error) { + logger.error(`Local kubectl failed to run properly (${error}), unlinking`); } await fs.promises.unlink(this.path); } @@ -278,12 +282,11 @@ export class Kubectl { } protected async writeInitScripts() { + const binariesDir = this.dependencies.baseBundeledBinariesDirectory; const kubectlPath = this.dependencies.userStore.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences()); - const binariesDir = baseBinariesDir.get(); - const bashScriptPath = path.join(this.dirname, ".bash_set_path"); const bashScript = [ initScriptVersionString, @@ -296,7 +299,7 @@ export class Kubectl { "elif test -f \"$HOME/.profile\"; then", " . \"$HOME/.profile\"", "fi", - `export PATH="${binariesDir}:${kubectlPath}:$PATH"`, + `export PATH="${kubectlPath}:${binariesDir}:$PATH"`, 'export KUBECONFIG="$tempkubeconfig"', `NO_PROXY=",\${NO_PROXY:-localhost},"`, `NO_PROXY="\${NO_PROXY//,localhost,/,}"`, @@ -327,7 +330,7 @@ export class Kubectl { "d=\":$PATH:\"", `d=\${d//$p/:}`, `d=\${d/#:/}`, - `export PATH="$binariesDir:$kubectlpath:\${d/%:/}"`, + `export PATH="$kubectlpath:$binariesDir:\${d/%:/}"`, "export KUBECONFIG=\"$tempkubeconfig\"", `NO_PROXY=",\${NO_PROXY:-localhost},"`, `NO_PROXY="\${NO_PROXY//,localhost,/,}"`, @@ -348,7 +351,8 @@ export class Kubectl { // MacOS packages are only available from default const { url } = packageMirrors.get(this.dependencies.userStore.downloadMirror) - ?? packageMirrors.get(defaultPackageMirror); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ?? packageMirrors.get(defaultPackageMirror)!; return url; } diff --git a/src/main/kubectl/normalized-arch.injectable.ts b/src/main/kubectl/normalized-arch.injectable.ts new file mode 100644 index 0000000000..88ec6b1067 --- /dev/null +++ b/src/main/kubectl/normalized-arch.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const kubectlDownloadingNormalizedArchInjectable = getInjectable({ + id: "kubectl-downloading-normalized-arch", + instantiate: () => { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + case "amd64": + return "amd64"; + case "386": + case "x32": + case "ia32": + return "386"; + default: + throw new Error(`arch=${process.arch} is unsupported`); + } + }, + causesSideEffects: true, +}); + +export default kubectlDownloadingNormalizedArchInjectable; diff --git a/src/main/lens-proxy/lens-proxy-port.injectable.ts b/src/main/lens-proxy/lens-proxy-port.injectable.ts new file mode 100644 index 0000000000..e6826f6df0 --- /dev/null +++ b/src/main/lens-proxy/lens-proxy-port.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const lensProxyPortInjectable = getInjectable({ + id: "lens-proxy-port", + + instantiate: () => { + let _portNumber: number; + + return { + get: () => { + if (!_portNumber) { + throw new Error( + "Tried to access port number of LensProxy while it has not been set yet.", + ); + } + + return _portNumber; + }, + + set: (portNumber: number) => { + if (_portNumber) { + throw new Error( + "Tried to set port number for LensProxy when it has already been set.", + ); + } + + _portNumber = portNumber; + }, + }; + }, +}); + +export default lensProxyPortInjectable; diff --git a/src/main/lens-proxy/lens-proxy.injectable.ts b/src/main/lens-proxy/lens-proxy.injectable.ts new file mode 100644 index 0000000000..34cb327339 --- /dev/null +++ b/src/main/lens-proxy/lens-proxy.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { LensProxy } from "./lens-proxy"; +import { kubeApiUpgradeRequest } from "./proxy-functions"; +import routerInjectable from "../router/router.injectable"; +import httpProxy from "http-proxy"; +import clusterManagerInjectable from "../cluster-manager.injectable"; +import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable"; +import lensProxyPortInjectable from "./lens-proxy-port.injectable"; + +const lensProxyInjectable = getInjectable({ + id: "lens-proxy", + + instantiate: (di) => { + const clusterManager = di.inject(clusterManagerInjectable); + const router = di.inject(routerInjectable); + const shellApiRequest = di.inject(shellApiRequestInjectable); + const proxy = httpProxy.createProxy(); + const lensProxyPort = di.inject(lensProxyPortInjectable); + + return new LensProxy({ + router, + proxy, + kubeApiUpgradeRequest, + shellApiRequest, + getClusterForRequest: clusterManager.getClusterForRequest, + lensProxyPort, + }); + }, +}); + +export default lensProxyInjectable; diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy/lens-proxy.ts similarity index 78% rename from src/main/lens-proxy.ts rename to src/main/lens-proxy/lens-proxy.ts index 945c7269e8..571cbea7b4 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy/lens-proxy.ts @@ -7,22 +7,28 @@ import net from "net"; import type http from "http"; import spdy from "spdy"; import type httpProxy from "http-proxy"; -import { apiPrefix, apiKubePrefix } from "../common/vars"; -import type { Router } from "./router/router"; -import type { ContextHandler } from "./context-handler/context-handler"; -import logger from "./logger"; -import { Singleton } from "../common/utils"; -import type { Cluster } from "../common/cluster/cluster"; +import { apiPrefix, apiKubePrefix } from "../../common/vars"; +import type { Router } from "../router/router"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; +import logger from "../logger"; +import type { Cluster } from "../../common/cluster/cluster"; import type { ProxyApiRequestArgs } from "./proxy-functions"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { getBoolean } from "./utils/parse-query"; +import { appEventBus } from "../../common/app-event-bus/event-bus"; +import { getBoolean } from "../utils/parse-query"; +import assert from "assert"; +import type { SetRequired } from "type-fest"; -type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null; +type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined; -export interface LensProxyFunctions { +export type ServerIncomingMessage = SetRequired; + +interface Dependencies { getClusterForRequest: GetClusterForRequest; shellApiRequest: (args: ProxyApiRequestArgs) => void | Promise; kubeApiUpgradeRequest: (args: ProxyApiRequestArgs) => void | Promise; + router: Router; + proxy: httpProxy; + lensProxyPort: { set: (portNumber: number) => void }; } const watchParam = "watch"; @@ -52,41 +58,33 @@ const disallowedPorts = new Set([ 10080, ]); -export class LensProxy extends Singleton { - protected origin: string; +export class LensProxy { protected proxyServer: http.Server; protected closed = false; protected retryCounters = new Map(); - protected getClusterForRequest: GetClusterForRequest; - public port: number; - - constructor(protected router: Router, protected proxy: httpProxy, { shellApiRequest, kubeApiUpgradeRequest, getClusterForRequest }: LensProxyFunctions) { - super(); - - this.configureProxy(proxy); - - this.getClusterForRequest = getClusterForRequest; + constructor(private dependencies: Dependencies) { + this.configureProxy(dependencies.proxy); this.proxyServer = spdy.createServer({ spdy: { plain: true, protocols: ["http/1.1", "spdy/3.1"], }, - }, (req: http.IncomingMessage, res: http.ServerResponse) => { - this.handleRequest(req, res); + }, (req, res) => { + this.handleRequest(req as ServerIncomingMessage, res); }); this.proxyServer - .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { - const cluster = getClusterForRequest(req); + .on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => { + const cluster = dependencies.getClusterForRequest(req); if (!cluster) { logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); socket.destroy(); } else { const isInternal = req.url.startsWith(`${apiPrefix}?`); - const reqHandler = isInternal ? shellApiRequest : kubeApiUpgradeRequest; + const reqHandler = isInternal ? dependencies.shellApiRequest : dependencies.kubeApiUpgradeRequest; (async () => reqHandler({ req, socket, head, cluster }))() .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); @@ -110,13 +108,14 @@ export class LensProxy extends Singleton { const { address, port } = this.proxyServer.address() as net.AddressInfo; + this.dependencies.lensProxyPort.set(port); + logger.info(`[LENS-PROXY]: Proxy server has started at ${address}:${port}`); this.proxyServer.on("error", (error) => { logger.info(`[LENS-PROXY]: Subsequent error: ${error}`); }); - this.port = port; appEventBus.emit({ name: "lens-proxy", action: "listen", params: { port }}); resolve(port); }) @@ -197,7 +196,7 @@ export class LensProxy extends Singleton { logger.debug(`Retrying proxy request to url: ${reqId}`); setTimeout(() => { this.retryCounters.set(reqId, retryCount + 1); - this.handleRequest(req, res) + this.handleRequest(req as ServerIncomingMessage, res) .catch(error => logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`)); }, timeoutMs); } @@ -214,8 +213,8 @@ export class LensProxy extends Singleton { return proxy; } - protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { - if (req.url.startsWith(apiKubePrefix)) { + protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise { + if (req.url?.startsWith(apiKubePrefix)) { delete req.headers.authorization; req.url = req.url.replace(apiKubePrefix, ""); @@ -223,20 +222,23 @@ export class LensProxy extends Singleton { } } - protected getRequestId(req: http.IncomingMessage) { + protected getRequestId(req: http.IncomingMessage): string { + assert(req.headers.host); + return req.headers.host + req.url; } - protected async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { - const cluster = this.getClusterForRequest(req); + protected async handleRequest(req: ServerIncomingMessage, res: http.ServerResponse) { + const cluster = this.dependencies.getClusterForRequest(req); if (cluster) { const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); if (proxyTarget) { - return this.proxy.web(req, res, proxyTarget); + return this.dependencies.proxy.web(req, res, proxyTarget); } } - this.router.route(cluster, req, res); + + this.dependencies.router.route(cluster, req, res); } } diff --git a/src/main/proxy-functions/index.ts b/src/main/lens-proxy/proxy-functions/index.ts similarity index 100% rename from src/main/proxy-functions/index.ts rename to src/main/lens-proxy/proxy-functions/index.ts diff --git a/src/main/proxy-functions/kube-api-upgrade-request.ts b/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts similarity index 80% rename from src/main/proxy-functions/kube-api-upgrade-request.ts rename to src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts index 899d22164e..ddd8e66261 100644 --- a/src/main/proxy-functions/kube-api-upgrade-request.ts +++ b/src/main/lens-proxy/proxy-functions/kube-api-upgrade-request.ts @@ -4,25 +4,26 @@ */ import { chunk } from "lodash"; -import tls from "tls"; +import type { ConnectionOptions } from "tls"; +import { connect } from "tls"; import url from "url"; -import { apiKubePrefix } from "../../common/vars"; +import { apiKubePrefix } from "../../../common/vars"; import type { ProxyApiRequestArgs } from "./types"; const skipRawHeaders = new Set(["Host", "Authorization"]); export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); - const proxyCa = await cluster.contextHandler.resolveAuthProxyCa(); + const proxyCa = cluster.contextHandler.resolveAuthProxyCa(); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); - const connectOpts = { - port: parseInt(pUrl.port), - host: pUrl.hostname, + const connectOpts: ConnectionOptions = { + port: pUrl.port ? parseInt(pUrl.port) : undefined, + host: pUrl.hostname ?? undefined, ca: proxyCa, }; - const proxySocket = tls.connect(connectOpts, () => { + const proxySocket = connect(connectOpts, () => { proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); proxySocket.write(`Host: ${apiUrl.host}\r\n`); diff --git a/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.injectable.ts similarity index 62% rename from src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts rename to src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.injectable.ts index 73477d1889..b491ab5456 100644 --- a/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts +++ b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.injectable.ts @@ -4,9 +4,9 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { shellApiRequest } from "./shell-api-request"; -import createShellSessionInjectable from "../../shell-session/create-shell-session.injectable"; -import shellRequestAuthenticatorInjectable - from "./shell-request-authenticator/shell-request-authenticator.injectable"; +import createShellSessionInjectable from "../../../shell-session/create-shell-session.injectable"; +import shellRequestAuthenticatorInjectable from "./shell-request-authenticator/shell-request-authenticator.injectable"; +import clusterManagerInjectable from "../../../cluster-manager.injectable"; const shellApiRequestInjectable = getInjectable({ id: "shell-api-request", @@ -14,6 +14,7 @@ const shellApiRequestInjectable = getInjectable({ instantiate: (di) => shellApiRequest({ createShellSession: di.inject(createShellSessionInjectable), authenticateRequest: di.inject(shellRequestAuthenticatorInjectable).authenticate, + clusterManager: di.inject(clusterManagerInjectable), }), }); diff --git a/src/main/proxy-functions/shell-api-request/shell-api-request.ts b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts similarity index 67% rename from src/main/proxy-functions/shell-api-request/shell-api-request.ts rename to src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts index 8a797f5f68..97b6dedd4c 100644 --- a/src/main/proxy-functions/shell-api-request/shell-api-request.ts +++ b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-api-request.ts @@ -3,17 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import logger from "../../logger"; +import logger from "../../../logger"; import type WebSocket from "ws"; import { Server as WebSocketServer } from "ws"; import type { ProxyApiRequestArgs } from "../types"; -import { ClusterManager } from "../../cluster-manager"; +import type { ClusterManager } from "../../../cluster-manager"; import URLParse from "url-parse"; -import type { Cluster } from "../../../common/cluster/cluster"; -import type { ClusterId } from "../../../common/cluster-types"; +import type { Cluster } from "../../../../common/cluster/cluster"; +import type { ClusterId } from "../../../../common/cluster-types"; interface Dependencies { - authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string) => boolean; + authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string | undefined) => boolean; createShellSession: (args: { webSocket: WebSocket; @@ -21,13 +21,15 @@ interface Dependencies { tabId: string; nodeName?: string; }) => { open: () => Promise }; + + clusterManager: ClusterManager; } -export const shellApiRequest = ({ createShellSession, authenticateRequest }: Dependencies) => ({ req, socket, head }: ProxyApiRequestArgs): void => { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); +export const shellApiRequest = ({ createShellSession, authenticateRequest, clusterManager }: Dependencies) => ({ req, socket, head }: ProxyApiRequestArgs): void => { + const cluster = clusterManager.getClusterForRequest(req); const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); - if (!cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { + if (!tabId || !cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { socket.write("Invalid shell request"); return void socket.end(); diff --git a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts similarity index 100% rename from src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts rename to src/main/lens-proxy/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts diff --git a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts similarity index 84% rename from src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts rename to src/main/lens-proxy/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts index bafd073c14..fe0a8f643d 100644 --- a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts +++ b/src/main/lens-proxy/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts @@ -2,9 +2,9 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getOrInsertMap } from "../../../../common/utils"; -import type { ClusterId } from "../../../../common/cluster-types"; -import { ipcMainHandle } from "../../../../common/ipc"; +import { getOrInsertMap } from "../../../../../common/utils"; +import type { ClusterId } from "../../../../../common/cluster-types"; +import { ipcMainHandle } from "../../../../../common/ipc"; import crypto from "crypto"; import { promisify } from "util"; @@ -31,10 +31,10 @@ export class ShellRequestAuthenticator { * @param token The value that is being presented as a one time authentication token * @returns `true` if `token` was valid, false otherwise */ - authenticate = (clusterId: ClusterId, tabId: string, token: string): boolean => { + authenticate = (clusterId: ClusterId, tabId: string, token: string | undefined): boolean => { const clusterTokens = this.tokens.get(clusterId); - if (!clusterTokens) { + if (!clusterTokens || !tabId || !token) { return false; } diff --git a/src/main/proxy-functions/types.ts b/src/main/lens-proxy/proxy-functions/types.ts similarity index 64% rename from src/main/proxy-functions/types.ts rename to src/main/lens-proxy/proxy-functions/types.ts index 4d7e3a684e..b6592ad244 100644 --- a/src/main/proxy-functions/types.ts +++ b/src/main/lens-proxy/proxy-functions/types.ts @@ -5,10 +5,11 @@ import type http from "http"; import type net from "net"; -import type { Cluster } from "../../common/cluster/cluster"; +import type { SetRequired } from "type-fest"; +import type { Cluster } from "../../../common/cluster/cluster"; export interface ProxyApiRequestArgs { - req: http.IncomingMessage; + req: SetRequired; socket: net.Socket; head: Buffer; cluster: Cluster; diff --git a/src/main/menu/application-menu-items.injectable.ts b/src/main/menu/application-menu-items.injectable.ts index 9cc2e26691..4ea447f675 100644 --- a/src/main/menu/application-menu-items.injectable.ts +++ b/src/main/menu/application-menu-items.injectable.ts @@ -3,30 +3,30 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { checkForUpdates } from "../app-updater"; import { docsUrl, productName, supportUrl } from "../../common/vars"; -import { exitApp } from "../exit-app"; import { broadcastMessage } from "../../common/ipc"; import { openBrowser } from "../../common/utils"; -import { showAbout } from "./menu"; -import windowManagerInjectable from "../window-manager.injectable"; import type { MenuItemConstructorOptions } from "electron"; -import { - webContents, -} from "electron"; +import { webContents } from "electron"; import loggerInjectable from "../../common/logger.injectable"; import appNameInjectable from "../app-paths/app-name/app-name.injectable"; import electronMenuItemsInjectable from "./electron-menu-items.injectable"; -import isAutoUpdateEnabledInjectable from "../is-auto-update-enabled.injectable"; +import updatingIsEnabledInjectable from "../application-update/updating-is-enabled.injectable"; import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import navigateToExtensionsInjectable from "../../common/front-end-routing/routes/extensions/navigate-to-extensions.injectable"; import navigateToCatalogInjectable from "../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; import navigateToWelcomeInjectable from "../../common/front-end-routing/routes/welcome/navigate-to-welcome.injectable"; import navigateToAddClusterInjectable from "../../common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable"; +import stopServicesAndExitAppInjectable from "../stop-services-and-exit-app.injectable"; import isMacInjectable from "../../common/vars/is-mac.injectable"; import { computed } from "mobx"; +import showAboutInjectable from "./show-about.injectable"; +import applicationWindowInjectable from "../start-main-application/lens-window/application-window/application-window.injectable"; +import reloadWindowInjectable from "../start-main-application/lens-window/reload-window.injectable"; +import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; +import processCheckingForUpdatesInjectable from "../application-update/check-for-updates/process-checking-for-updates.injectable"; -function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) { +function ignoreIf(check: boolean, menuItems: MenuItemOpts[]) { return check ? [] : menuItems; } @@ -41,24 +41,23 @@ const applicationMenuItemsInjectable = getInjectable({ const logger = di.inject(loggerInjectable); const appName = di.inject(appNameInjectable); const isMac = di.inject(isMacInjectable); - const isAutoUpdateEnabled = di.inject(isAutoUpdateEnabledInjectable); + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); const electronMenuItems = di.inject(electronMenuItemsInjectable); + const showAbout = di.inject(showAboutInjectable); + const applicationWindow = di.inject(applicationWindowInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const reloadApplicationWindow = di.inject(reloadWindowInjectable, applicationWindow); + const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + const navigateToExtensions = di.inject(navigateToExtensionsInjectable); + const navigateToCatalog = di.inject(navigateToCatalogInjectable); + const navigateToWelcome = di.inject(navigateToWelcomeInjectable); + const navigateToAddCluster = di.inject(navigateToAddClusterInjectable); + const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); + const processCheckingForUpdates = di.inject(processCheckingForUpdatesInjectable); + + logger.info(`[MENU]: autoUpdateEnabled=${updatingIsEnabled}`); return computed((): MenuItemOpts[] => { - - // TODO: These injects should happen outside of the computed. - // TODO: Remove temporal dependencies in WindowManager to make sure timing is correct. - const windowManager = di.inject(windowManagerInjectable); - const navigateToPreferences = di.inject(navigateToPreferencesInjectable); - const navigateToExtensions = di.inject(navigateToExtensionsInjectable); - const navigateToCatalog = di.inject(navigateToCatalogInjectable); - const navigateToWelcome = di.inject(navigateToWelcomeInjectable); - const navigateToAddCluster = di.inject(navigateToAddClusterInjectable); - - const autoUpdateDisabled = !isAutoUpdateEnabled(); - - logger.info(`[MENU]: autoUpdateDisabled=${autoUpdateDisabled}`); - const macAppMenu: MenuItemOpts = { label: appName, id: "root", @@ -70,11 +69,11 @@ const applicationMenuItemsInjectable = getInjectable({ showAbout(); }, }, - ...ignoreIf(autoUpdateDisabled, [ + ...ignoreIf(!updatingIsEnabled, [ { label: "Check for updates", click() { - checkForUpdates().then(() => windowManager.ensureMainWindow()); + processCheckingForUpdates().then(() => showApplicationWindow()); }, }, ]), @@ -107,7 +106,7 @@ const applicationMenuItemsInjectable = getInjectable({ accelerator: "Cmd+Q", id: "quit", click() { - exitApp(); + stopServicesAndExitApp(); }, }, ], @@ -142,26 +141,21 @@ const applicationMenuItemsInjectable = getInjectable({ }, }, ]), - { type: "separator" }, - - ...(isMac - ? ([ - { - role: "close", - label: "Close Window", - accelerator: "Shift+Cmd+W", - }, - ] as MenuItemConstructorOptions[]) - : []), - + ...ignoreIf(!isMac, [ + { + role: "close", + label: "Close Window", + accelerator: "Shift+Cmd+W", + }, + ]), ...ignoreIf(isMac, [ { label: "Exit", accelerator: "Alt+F4", id: "quit", click() { - exitApp(); + stopServicesAndExitApp(); }, }, ]), @@ -238,7 +232,7 @@ const applicationMenuItemsInjectable = getInjectable({ accelerator: "CmdOrCtrl+R", id: "reload", click() { - windowManager.reload(); + reloadApplicationWindow(); }, }, { role: "toggleDevTools" }, @@ -287,12 +281,12 @@ const applicationMenuItemsInjectable = getInjectable({ showAbout(); }, }, - ...ignoreIf(autoUpdateDisabled, [ + ...ignoreIf(!updatingIsEnabled, [ { label: "Check for updates", click() { - checkForUpdates().then(() => - windowManager.ensureMainWindow(), + processCheckingForUpdates().then(() => + showApplicationWindow(), ); }, }, @@ -311,7 +305,9 @@ const applicationMenuItemsInjectable = getInjectable({ // Modify menu from extensions-api for (const menuItem of electronMenuItems.get()) { - if (!appMenu.has(menuItem.parentId)) { + const parentMenu = appMenu.get(menuItem.parentId); + + if (!parentMenu) { logger.error( `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`, { menuItem }, @@ -320,7 +316,7 @@ const applicationMenuItemsInjectable = getInjectable({ continue; } - appMenu.get(menuItem.parentId).submenu.push(menuItem); + (parentMenu.submenu ??= []).push(menuItem); } if (!isMac) { diff --git a/src/main/menu/application-menu.injectable.ts b/src/main/menu/application-menu.injectable.ts new file mode 100644 index 0000000000..633ccedbc4 --- /dev/null +++ b/src/main/menu/application-menu.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { autorun } from "mobx"; +import { buildMenu } from "./menu"; +import applicationMenuItemsInjectable from "./application-menu-items.injectable"; +import { getStartableStoppable } from "../../common/utils/get-startable-stoppable"; + +const applicationMenuInjectable = getInjectable({ + id: "application-menu", + + instantiate: (di) => { + const applicationMenuItems = di.inject(applicationMenuItemsInjectable); + + return getStartableStoppable("build-of-application-menu", () => + autorun(() => buildMenu(applicationMenuItems.get()), { + delay: 100, + }), + ); + }, +}); + +export default applicationMenuInjectable; diff --git a/src/main/menu/electron-menu-items.test.ts b/src/main/menu/electron-menu-items.test.ts index b1b2dd61b1..cc47994d82 100644 --- a/src/main/menu/electron-menu-items.test.ts +++ b/src/main/menu/electron-menu-items.test.ts @@ -16,7 +16,7 @@ describe("electron-menu-items", () => { let electronMenuItems: IComputedValue; let extensionsStub: ObservableMap; - beforeEach(async () => { + beforeEach(() => { di = getDiForUnitTesting({ doGeneralOverrides: true }); extensionsStub = new ObservableMap(); @@ -26,8 +26,6 @@ describe("electron-menu-items", () => { () => computed(() => [...extensionsStub.values()]), ); - await di.runSetups(); - electronMenuItems = di.inject(electronMenuItemsInjectable); }); @@ -109,7 +107,7 @@ class SomeTestExtension extends LensMainExtension { isBundled: false, isCompatible: false, isEnabled: false, - manifest: { name: id, version: "some-version" }, + manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }}, manifestPath: "irrelevant", }); diff --git a/src/main/menu/menu.ts b/src/main/menu/menu.ts index 4936957b80..fd6bc6c264 100644 --- a/src/main/menu/menu.ts +++ b/src/main/menu/menu.ts @@ -2,24 +2,19 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { app, dialog, Menu } from "electron"; -import type { IComputedValue } from "mobx"; -import { autorun } from "mobx"; +import { app, Menu } from "electron"; import { appName, isWindows, productName } from "../../common/vars"; import packageJson from "../../../package.json"; import type { MenuItemOpts } from "./application-menu-items.injectable"; +import type { ShowMessagePopup } from "../electron-app/features/show-message-popup.injectable"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; -export function initMenu( - applicationMenuItems: IComputedValue, -) { - return autorun(() => buildMenu(applicationMenuItems.get()), { - delay: 100, - }); +interface Dependencies { + showMessagePopup: ShowMessagePopup; } -export function showAbout() { +export const showAbout = ({ showMessagePopup }: Dependencies) => async () => { const appInfo = [ `${appName}: ${app.getVersion()}`, `Electron: ${process.versions.electron}`, @@ -28,18 +23,14 @@ export function showAbout() { packageJson.copyright, ]; - dialog.showMessageBoxSync({ - title: `${isWindows ? " ".repeat(2) : ""}${appName}`, - type: "info", - buttons: ["Close"], - message: productName, - detail: appInfo.join("\r\n"), - }); -} + await showMessagePopup( + `${isWindows ? " ".repeat(2) : ""}${appName}`, + productName, + appInfo.join("\r\n"), + ); +}; -export function buildMenu( - applicationMenuItems: MenuItemOpts[], -) { +export function buildMenu(applicationMenuItems: MenuItemOpts[]) { Menu.setApplicationMenu( Menu.buildFromTemplate(applicationMenuItems), ); diff --git a/src/main/menu/show-about.injectable.ts b/src/main/menu/show-about.injectable.ts new file mode 100644 index 0000000000..e79923d4c2 --- /dev/null +++ b/src/main/menu/show-about.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { showAbout } from "./menu"; +import showMessagePopupInjectable from "../electron-app/features/show-message-popup.injectable"; + +const showAboutInjectable = getInjectable({ + id: "show-about", + + instantiate: (di) => + showAbout({ showMessagePopup: di.inject(showMessagePopupInjectable) }), +}); + +export default showAboutInjectable; diff --git a/src/main/menu/start-application-menu.injectable.ts b/src/main/menu/start-application-menu.injectable.ts new file mode 100644 index 0000000000..b241137cec --- /dev/null +++ b/src/main/menu/start-application-menu.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationMenuInjectable from "./application-menu.injectable"; +import { onLoadOfApplicationInjectionToken } from "../start-main-application/runnable-tokens/on-load-of-application-injection-token"; + +const startApplicationMenuInjectable = getInjectable({ + id: "start-application-menu", + + instantiate: (di) => { + const applicationMenu = di.inject( + applicationMenuInjectable, + ); + + return { + run: async () => { + await applicationMenu.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startApplicationMenuInjectable; diff --git a/src/main/menu/stop-application-menu.injectable.ts b/src/main/menu/stop-application-menu.injectable.ts new file mode 100644 index 0000000000..1492da32de --- /dev/null +++ b/src/main/menu/stop-application-menu.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationMenuInjectable from "./application-menu.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopApplicationMenuInjectable = getInjectable({ + id: "stop-application-menu", + + instantiate: (di) => { + const applicationMenu = di.inject( + applicationMenuInjectable, + ); + + return { + run: async () => { + await applicationMenu.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopApplicationMenuInjectable; diff --git a/src/main/native-theme.ts b/src/main/native-theme.ts deleted file mode 100644 index e729245666..0000000000 --- a/src/main/native-theme.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { nativeTheme } from "electron"; -import { broadcastMessage } from "../common/ipc"; -import { setNativeThemeChannel } from "../common/ipc/native-theme"; - -export function broadcastNativeThemeOnUpdate() { - nativeTheme.on("updated", () => { - broadcastMessage(setNativeThemeChannel, getNativeColorTheme()); - }); -} - -export function getNativeColorTheme() { - return nativeTheme.shouldUseDarkColors ? "dark" : "light"; -} diff --git a/src/main/navigate-to-route/navigate-to-route.injectable.ts b/src/main/navigate-to-route/navigate-to-route.injectable.ts index 12bf077ced..e097bf1569 100644 --- a/src/main/navigate-to-route/navigate-to-route.injectable.ts +++ b/src/main/navigate-to-route/navigate-to-route.injectable.ts @@ -13,7 +13,7 @@ const navigateToRouteInjectable = getInjectable({ instantiate: (di) => { const navigateToUrl = di.inject(navigateToUrlInjectionToken); - return (route, options) => { + return async (route, options) => { const url = buildURL(route.path, { // TODO: enhance typing params: options?.parameters as any, @@ -21,7 +21,7 @@ const navigateToRouteInjectable = getInjectable({ fragment: options?.fragment, }); - navigateToUrl(url, options); + await navigateToUrl(url, options); }; }, diff --git a/src/main/navigate-to-url/navigate-to-url.injectable.ts b/src/main/navigate-to-url/navigate-to-url.injectable.ts index 5a3cd2bf49..fcef5680c1 100644 --- a/src/main/navigate-to-url/navigate-to-url.injectable.ts +++ b/src/main/navigate-to-url/navigate-to-url.injectable.ts @@ -3,17 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import windowManagerInjectable from "../window-manager.injectable"; import { navigateToUrlInjectionToken } from "../../common/front-end-routing/navigate-to-url-injection-token"; +import navigateInjectable from "../start-main-application/lens-window/navigate.injectable"; const navigateToUrlInjectable = getInjectable({ id: "navigate-to-url", instantiate: (di) => { - const windowManager = di.inject(windowManagerInjectable); + const navigate = di.inject(navigateInjectable); - return (url) => { - windowManager.navigateSync(url); + return async (url) => { + await navigate(url); }; }, diff --git a/src/main/prometheus/prometheus-provider-registry.injectable.ts b/src/main/prometheus/prometheus-provider-registry.injectable.ts new file mode 100644 index 0000000000..bac9e1a728 --- /dev/null +++ b/src/main/prometheus/prometheus-provider-registry.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { PrometheusProviderRegistry } from "./provider-registry"; + +const prometheusProviderRegistryInjectable = getInjectable({ + id: "prometheus-provider-registry", + instantiate: () => new PrometheusProviderRegistry(), +}); + +export default prometheusProviderRegistryInjectable; diff --git a/src/main/prometheus/provider-registry.ts b/src/main/prometheus/provider-registry.ts index 21c64d7a67..c63b22f374 100644 --- a/src/main/prometheus/provider-registry.ts +++ b/src/main/prometheus/provider-registry.ts @@ -4,8 +4,7 @@ */ import type { CoreV1Api } from "@kubernetes/client-node"; -import { inspect } from "util"; -import { Singleton } from "../../common/utils"; +import { isRequestError } from "../../common/utils"; export interface PrometheusService { id: string; @@ -30,9 +29,9 @@ export abstract class PrometheusProvider { protected async getFirstNamespacedService(client: CoreV1Api, ...selectors: string[]): Promise { try { for (const selector of selectors) { - const { body: { items: [service] }} = await client.listServiceForAllNamespaces(null, null, null, selector); + const { body: { items: [service] }} = await client.listServiceForAllNamespaces(undefined, undefined, undefined, selector); - if (service) { + if (service?.metadata?.namespace && service.metadata.name && service.spec?.ports) { return { id: this.id, namespace: service.metadata.namespace, @@ -42,7 +41,7 @@ export abstract class PrometheusProvider { } } } catch (error) { - throw new Error(`Failed to list services for Prometheus${this.name} in all namespaces: ${error.response.body.message}`); + throw new Error(`Failed to list services for Prometheus${this.name} in all namespaces: ${isRequestError(error) ? error.response?.body.message : error}`); } throw new Error(`No service found for Prometheus${this.name} from any namespace`); @@ -50,8 +49,11 @@ export abstract class PrometheusProvider { protected async getNamespacedService(client: CoreV1Api, name: string, namespace: string): Promise { try { - const resp = await client.readNamespacedService(name, namespace); - const service = resp.body; + const { body: service } = await client.readNamespacedService(name, namespace); + + if (!service.metadata?.namespace || !service.metadata.name || !service.spec?.ports) { + throw new Error(`Service returned from Prometheus${this.name} in namespace="${namespace}" did not have required information`); + } return { id: this.id, @@ -60,12 +62,12 @@ export abstract class PrometheusProvider { port: service.spec.ports[0].port, }; } catch(error) { - throw new Error(`Failed to list services for Prometheus${this.name} in namespace=${inspect(namespace, false, undefined, false)}: ${error.response.body.message}`); + throw new Error(`Failed to list services for Prometheus${this.name} in namespace="${namespace}": ${isRequestError(error) ? error.response?.body.message : error}`); } } } -export class PrometheusProviderRegistry extends Singleton { +export class PrometheusProviderRegistry { public providers = new Map(); getByKind(kind: string): PrometheusProvider { diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index d8c88f7f22..1fd6797038 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -8,16 +8,19 @@ import * as uuid from "uuid"; import { broadcastMessage } from "../../../common/ipc"; import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; import { delay, noop } from "../../../common/utils"; -import { LensExtension } from "../../../extensions/main-api"; import { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main"; import mockFs from "mock-fs"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; -import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; import lensProtocolRouterMainInjectable from "../lens-protocol-router-main/lens-protocol-router-main.injectable"; import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable"; import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import { LensExtension } from "../../../extensions/lens-extension"; +import type { LensExtensionId } from "../../../extensions/lens-extension"; +import type { ObservableMap } from "mobx"; +import extensionInstancesInjectable from "../../../extensions/extension-loader/extension-instances.injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; jest.mock("../../../common/ipc"); @@ -28,9 +31,7 @@ function throwIfDefined(val: any): void { } describe("protocol router tests", () => { - // TODO: This test suite is using any to access protected property. - // Unit tests are allowed to only public interfaces. - let extensionLoader: any; + let extensionInstances: ObservableMap; let lpr: LensProtocolRouterMain; let extensionsStore: ExtensionsStore; @@ -46,12 +47,10 @@ describe("protocol router tests", () => { di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); - await di.runSetups(); - - extensionLoader = di.inject(extensionLoaderInjectable); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + extensionInstances = di.inject(extensionInstancesInjectable); extensionsStore = di.inject(extensionsStoreInjectable); - lpr = di.inject(lensProtocolRouterMainInjectable); lpr.rendererLoaded = true; @@ -87,6 +86,7 @@ describe("protocol router tests", () => { manifest: { name: "@mirantis/minikube", version: "0.1.1", + engines: { lens: "^5.5.0" }, }, isBundled: false, isEnabled: true, @@ -99,8 +99,8 @@ describe("protocol router tests", () => { handler: noop, }); - extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); + extensionInstances.set(extId, ext); + extensionsStore.mergeState([[extId, { enabled: true, name: "@mirantis/minikube" }]]); lpr.addInternalHandler("/", noop); @@ -162,6 +162,7 @@ describe("protocol router tests", () => { manifest: { name: "@foobar/icecream", version: "0.1.1", + engines: { lens: "^5.5.0" }, }, isBundled: false, isEnabled: true, @@ -178,8 +179,8 @@ describe("protocol router tests", () => { handler: params => { called = params.pathname.id; }, }); - extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + extensionInstances.set(extId, ext); + extensionsStore.mergeState([[extId, { enabled: true, name: "@foobar/icecream" }]]); try { expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); @@ -203,6 +204,7 @@ describe("protocol router tests", () => { manifest: { name: "@foobar/icecream", version: "0.1.1", + engines: { lens: "^5.5.0" }, }, isBundled: false, isEnabled: true, @@ -216,8 +218,8 @@ describe("protocol router tests", () => { handler: params => { called = params.pathname.id; }, }); - extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + extensionInstances.set(extId, ext); + extensionsStore.mergeState([[extId, { enabled: true, name: "@foobar/icecream" }]]); } { @@ -228,6 +230,7 @@ describe("protocol router tests", () => { manifest: { name: "icecream", version: "0.1.1", + engines: { lens: "^5.5.0" }, }, isBundled: false, isEnabled: true, @@ -241,12 +244,14 @@ describe("protocol router tests", () => { handler: () => { called = 1; }, }); - extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" }); + extensionInstances.set(extId, ext); + extensionsStore.mergeState([[extId, { enabled: true, name: "icecream" }]]); } - (extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); - (extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" }); + extensionsStore.mergeState([ + ["@foobar/icecream", { enabled: true, name: "@foobar/icecream" }], + ["icecream", { enabled: true, name: "icecream" }], + ]); try { expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts index f09a7c2b8a..630d63d82c 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts @@ -6,6 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; import { LensProtocolRouterMain } from "./lens-protocol-router-main"; import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable"; +import showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; const lensProtocolRouterMainInjectable = getInjectable({ id: "lens-protocol-router-main", @@ -14,6 +15,7 @@ const lensProtocolRouterMainInjectable = getInjectable({ new LensProtocolRouterMain({ extensionLoader: di.inject(extensionLoaderInjectable), extensionsStore: di.inject(extensionsStoreInjectable), + showApplicationWindow: di.inject(showApplicationWindowInjectable), }), }); diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts index e40ba5fa11..56270ac6b2 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts @@ -12,7 +12,6 @@ import { observable, when, makeObservable } from "mobx"; import type { RouteAttempt } from "../../../common/protocol-handler"; import { ProtocolHandlerInvalid } from "../../../common/protocol-handler"; import { disposer, noop } from "../../../common/utils"; -import { WindowManager } from "../../window-manager"; import type { ExtensionLoader } from "../../../extensions/extension-loader"; import type { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; @@ -40,11 +39,13 @@ function checkHost(url: URLParse): boolean { interface Dependencies { extensionLoader: ExtensionLoader; extensionsStore: ExtensionsStore; + showApplicationWindow: () => Promise; } export class LensProtocolRouterMain extends proto.LensProtocolRouter { private missingExtensionHandlers: FallbackHandler[] = []; + // TODO: This is used to solve out-of-place temporal dependency. Remove, and solve otherwise. @observable rendererLoaded = false; protected disposers = disposer(); @@ -73,7 +74,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); } - WindowManager.getInstance(false)?.ensureMainWindow().catch(noop); + this.dependencies.showApplicationWindow().catch(noop); const routeInternally = checkHost(url); logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`); @@ -84,7 +85,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { await this._routeToExtension(url); } } catch (error) { - broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl); + broadcastMessage(ProtocolHandlerInvalid, error ? String(error) : "unknown error", rawUrl); if (error instanceof proto.RoutingError) { logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); @@ -118,7 +119,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return ""; } - protected _routeToInternal(url: URLParse>): RouteAttempt { + protected _routeToInternal(url: URLParse>): RouteAttempt { const rawUrl = url.toString(); // for sending to renderer const attempt = super._routeToInternal(url); @@ -127,7 +128,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return attempt; } - protected async _routeToExtension(url: URLParse>): Promise { + protected async _routeToExtension(url: URLParse>): Promise { const rawUrl = url.toString(); // for sending to renderer /** diff --git a/src/main/protocol-handler/lens-protocol-router-main/open-deep-link-for-url/open-deep-link.injectable.ts b/src/main/protocol-handler/lens-protocol-router-main/open-deep-link-for-url/open-deep-link.injectable.ts new file mode 100644 index 0000000000..71e42b9f3c --- /dev/null +++ b/src/main/protocol-handler/lens-protocol-router-main/open-deep-link-for-url/open-deep-link.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import lensProtocolRouterMainInjectable from "../lens-protocol-router-main.injectable"; + +const openDeepLinkInjectable = getInjectable({ + id: "open-deep-link", + + instantiate: (di) => { + const getProtocolRouter = () => di.inject(lensProtocolRouterMainInjectable); + + return async (url: string) => { + await getProtocolRouter().route(url); + }; + }, +}); + +export default openDeepLinkInjectable; diff --git a/src/main/proxy-env.ts b/src/main/proxy-env.ts deleted file mode 100644 index e6f98554af..0000000000 --- a/src/main/proxy-env.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { app } from "electron"; - -const switchValue = app.commandLine.getSwitchValue("proxy-server"); - -export function mangleProxyEnv() { - let httpsProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || ""; - - delete process.env.HTTPS_PROXY; - delete process.env.HTTP_PROXY; - - if (switchValue !== "") { - httpsProxy = switchValue; - } - - if (httpsProxy !== "") { - process.env.APP_HTTPS_PROXY = httpsProxy; - } -} diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 11c26ad171..30c85e8fa0 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -12,7 +12,7 @@ import path from "path"; import tempy from "tempy"; import logger from "./logger"; import { appEventBus } from "../common/app-event-bus/event-bus"; -import { cloneJsonObject } from "../common/utils"; +import { isChildProcessError } from "../common/utils"; import type { Patch } from "rfc6902"; import { promiseExecFile } from "../common/utils/promise-exec"; @@ -54,7 +54,11 @@ export class ResourceApplier { return stdout; } catch (error) { - throw error.stderr ?? error; + if (isChildProcessError(error)) { + throw error.stderr ?? error; + } + + throw error; } } @@ -92,7 +96,11 @@ export class ResourceApplier { return stdout; } catch (error) { - throw error?.stderr ?? error; + if (isChildProcessError(error)) { + throw error.stderr ?? error; + } + + throw error; } finally { await fs.unlink(fileName); } @@ -142,15 +150,16 @@ export class ResourceApplier { } protected sanitizeObject(resource: KubernetesObject | any) { - resource = cloneJsonObject(resource); - delete resource.status; - delete resource.metadata?.resourceVersion; - const annotations = resource.metadata?.annotations; + const res = JSON.parse(JSON.stringify(resource)); + + delete res.status; + delete res.metadata?.resourceVersion; + const annotations = res.metadata?.annotations; if (annotations) { delete annotations["kubectl.kubernetes.io/last-applied-configuration"]; } - return resource; + return res; } } diff --git a/src/main/router/route.ts b/src/main/router/route.ts new file mode 100644 index 0000000000..90b0210746 --- /dev/null +++ b/src/main/router/route.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Cluster } from "../../common/cluster/cluster"; +import type http from "http"; +import type httpProxy from "http-proxy"; +import type { LensApiResultContentType } from "./router-content-types"; +import type { URLSearchParams } from "url"; +import type Joi from "joi"; + +export type InferParam< + T extends string, + PathParams extends Record, +> = + T extends `{${infer P}?}` + ? PathParams & Partial> + : T extends `{${infer P}}` + ? PathParams & Record + : PathParams; + +export type InferParamFromPath

= + P extends `${string}/{${infer B}*}${infer Tail}` + ? Tail extends "" + ? Record + : never + : P extends `${infer A}/${infer B}` + ? InferParam> + : InferParam; + +export interface LensApiRequest { + path: Path; + payload: unknown; + params: InferParamFromPath; + cluster: Cluster | undefined; + query: URLSearchParams; + raw: { + req: http.IncomingMessage; + res: http.ServerResponse; + }; +} + +export interface ClusterLensApiRequest extends LensApiRequest { + cluster: Cluster; +} + +export interface LensApiResult { + statusCode?: number; + response?: Response; + error?: any; + contentType?: LensApiResultContentType; + headers?: Partial>; + proxy?: httpProxy; +} + +export type RouteResponse = + | LensApiResult + | void; + +export interface RouteHandler{ + (request: LensApiRequest): RouteResponse | Promise>; +} + +export interface BaseRoutePaths { + path: Path; + method: "get" | "post" | "put" | "patch" | "delete"; +} + +export interface PayloadValidator { + validate(payload: unknown): Joi.ValidationResult; +} + +export interface ValidatorBaseRoutePaths extends BaseRoutePaths { + payloadValidator: PayloadValidator; +} + +export interface Route extends BaseRoutePaths { + handler: RouteHandler; +} + +export interface BindHandler { + (handler: RouteHandler): Route; +} + +export function route(parts: BaseRoutePaths): BindHandler { + return (handler) => ({ + ...parts, + handler, + }); +} + +export interface ClusterRouteHandler{ + (request: ClusterLensApiRequest): RouteResponse | Promise>; +} + +export interface BindClusterHandler { + (handler: ClusterRouteHandler): Route; +} + +export function clusterRoute(parts: BaseRoutePaths): BindClusterHandler { + return (handler) => ({ + ...parts, + handler: ({ cluster, ...rest }) => { + if (!cluster) { + return { + error: "Cluster missing", + statusCode: 400, + }; + } + + return handler({ cluster, ...rest }); + }, + }); +} + +export interface ValidatedClusterLensApiRequest extends ClusterLensApiRequest { + payload: Payload; +} + +export interface ValidatedClusterRouteHandler { + (request: ValidatedClusterLensApiRequest): RouteResponse | Promise>; +} + +export interface BindValidatedClusterHandler { + (handler: ValidatedClusterRouteHandler): Route; +} + +export function payloadValidatedClusterRoute({ payloadValidator, ...parts }: ValidatorBaseRoutePaths): BindValidatedClusterHandler { + const boundClusterRoute = clusterRoute(parts); + + return (handler) => boundClusterRoute(({ payload, ...rest }) => { + const validationResult = payloadValidator.validate(payload); + + if (validationResult.error) { + return { + error: validationResult.error, + statusCode: 400, + }; + } + + return handler({ + payload: validationResult.value, + ...rest, + }); + }); +} diff --git a/src/main/router/router-content-types.ts b/src/main/router/router-content-types.ts index 8271c1ea06..4106e6b750 100644 --- a/src/main/router/router-content-types.ts +++ b/src/main/router/router-content-types.ts @@ -2,7 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiResult } from "./router"; +import type { LensApiResult } from "./route"; export interface LensApiResultContentType { resultMapper: (result: LensApiResult) => ({ diff --git a/src/main/router/router.injectable.ts b/src/main/router/router.injectable.ts index 138219d035..23d6c78fc4 100644 --- a/src/main/router/router.injectable.ts +++ b/src/main/router/router.injectable.ts @@ -2,15 +2,29 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; -import type { Route } from "./router"; +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken, lifecycleEnum } from "@ogre-tools/injectable"; import { Router } from "./router"; import parseRequestInjectable from "./parse-request.injectable"; +import type { Route } from "./route"; -export const routeInjectionToken = getInjectionToken>({ +export const routeInjectionToken = getInjectionToken>({ id: "route-injection-token", }); +export function getRouteInjectable< + T, + Path extends string, +>( + opts: Omit, Route, void>, "lifecycle" | "injectionToken">, +): Injectable, Route, void> { + return { + ...opts, + injectionToken: routeInjectionToken as never, + lifecycle: lifecycleEnum.singleton as never, + }; +} + const routerInjectable = getInjectable({ id: "router", diff --git a/src/main/router/router.test.ts b/src/main/router/router.test.ts index 96f0587163..20d5f4f526 100644 --- a/src/main/router/router.test.ts +++ b/src/main/router/router.test.ts @@ -5,7 +5,7 @@ import routerInjectable, { routeInjectionToken } from "./router.injectable"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; -import type { Router, RouteHandler, Route } from "./router"; +import type { Router } from "./router"; import type { Cluster } from "../../common/cluster/cluster"; import { Request } from "mock-http"; import { getInjectable } from "@ogre-tools/injectable"; @@ -14,10 +14,16 @@ import asyncFn from "@async-fn/jest"; import parseRequestInjectable from "./parse-request.injectable"; import { contentTypes } from "./router-content-types"; import mockFs from "mock-fs"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import type { Route } from "./route"; +import type { SetRequired } from "type-fest"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; describe("router", () => { let router: Router; - let routeHandlerMock: AsyncFnMock>; + let routeHandlerMock: AsyncFnMock<() => any>; beforeEach(async () => { routeHandlerMock = asyncFn(); @@ -27,8 +33,10 @@ describe("router", () => { mockFs(); di.override(parseRequestInjectable, () => () => Promise.resolve({ payload: "some-payload" })); - - await di.runSetups(); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); const injectable = getInjectable({ id: "some-route", @@ -37,7 +45,7 @@ describe("router", () => { method: "get", path: "/some-path", handler: routeHandlerMock, - } as Route), + } as Route), injectionToken: routeInjectionToken, }); @@ -54,7 +62,7 @@ describe("router", () => { describe("when navigating to the route", () => { let actualPromise: Promise; let clusterStub: Cluster; - let requestStub: Request; + let requestStub: SetRequired; let responseStub: any; beforeEach(() => { @@ -64,7 +72,7 @@ describe("router", () => { headers: { "content-type": "application/json", }, - }); + }) as SetRequired; responseStub = { end: jest.fn(), setHeader: jest.fn(), write: jest.fn(), statusCode: undefined }; diff --git a/src/main/router/router.ts b/src/main/router/router.ts index 545462ead6..98ce50bac3 100644 --- a/src/main/router/router.ts +++ b/src/main/router/router.ts @@ -5,42 +5,20 @@ import Call from "@hapi/call"; import type http from "http"; -import type httpProxy from "http-proxy"; import { toPairs } from "lodash/fp"; import type { Cluster } from "../../common/cluster/cluster"; -import type { LensApiResultContentType } from "./router-content-types"; import { contentTypes } from "./router-content-types"; +import type { LensApiRequest, LensApiResult, Route } from "./route"; +import type { ServerIncomingMessage } from "../lens-proxy/lens-proxy"; export interface RouterRequestOpts { req: http.IncomingMessage; res: http.ServerResponse; - cluster: Cluster; - params: RouteParams; + cluster: Cluster | undefined; + params: any; url: URL; } -export interface RouteParams extends Record { - path?: string; // *-route - namespace?: string; - service?: string; - account?: string; - release?: string; - repo?: string; - chart?: string; -} - -export interface LensApiRequest

{ - path: string; - payload: P; - params: RouteParams; - cluster: Cluster; - query: URLSearchParams; - raw: { - req: http.IncomingMessage; - res: http.ServerResponse; - }; -} - interface Dependencies { parseRequest: (request: http.IncomingMessage, _: null, options: { parse: boolean; output: string }) => Promise<{ payload: any }>; } @@ -48,13 +26,13 @@ interface Dependencies { export class Router { protected router = new Call.Router(); - constructor(routes: Route[], private dependencies: Dependencies) { + constructor(routes: Route[], private dependencies: Dependencies) { routes.forEach(route => { this.router.add({ method: route.method, path: route.path }, handleRoute(route)); }); } - public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { + public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url, "http://localhost"); const path = url.pathname; const method = req.method.toLowerCase(); @@ -72,7 +50,7 @@ export class Router { return false; } - protected async getRequest(opts: RouterRequestOpts): Promise { + protected async getRequest(opts: RouterRequestOpts): Promise> { const { req, res, url, cluster, params } = opts; const { payload } = await this.dependencies.parseRequest(req, null, { @@ -86,37 +64,14 @@ export class Router { raw: { req, res, }, - query: url.searchParams, + query: url.searchParams as never, payload, params, }; } } -export interface LensApiResult { - statusCode?: number; - response?: TResult; - error?: any; - contentType?: LensApiResultContentType; - headers?: { [name: string]: string }; - proxy?: httpProxy; -} - -export type RouteHandler = ( - request: LensApiRequest -) => - | Promise> - | Promise - | LensApiResult - | void; - -export interface Route { - path: string; - method: "get" | "post" | "put" | "patch" | "delete"; - handler: RouteHandler; -} - -const handleRoute = (route: Route) => async (request: LensApiRequest, response: http.ServerResponse) => { +const handleRoute = (route: Route) => async (request: LensApiRequest, response: http.ServerResponse) => { let result: LensApiResult | void; const writeServerResponse = writeServerResponseFor(response); @@ -126,7 +81,7 @@ const handleRoute = (route: Route) => async (request: LensApiRequest, r } catch(error) { const mappedResult = contentTypes.txt.resultMapper({ statusCode: 500, - error: error.toString(), + error: error ? String(error) : "unknown error", }); writeServerResponse(mappedResult); diff --git a/src/main/routes/helm/charts/get-chart-route.injectable.ts b/src/main/routes/helm/charts/get-chart-route.injectable.ts index 523be0e1a6..cc711be5d5 100644 --- a/src/main/routes/helm/charts/get-chart-route.injectable.ts +++ b/src/main/routes/helm/charts/get-chart-route.injectable.ts @@ -2,35 +2,28 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import type { Route } from "../../../router/router"; -import { helmService } from "../../../helm/helm-service"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { apiPrefix } from "../../../../common/vars"; -import type { RawHelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api"; +import { route } from "../../../router/route"; +import { helmService } from "../../../helm/helm-service"; -interface GetChartResponse { - readme: string; - versions: RawHelmChart[]; -} - -const getChartRouteInjectable = getInjectable({ +const getChartRouteInjectable = getRouteInjectable({ id: "get-chart-route", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}`, + })(async ({ params, query }) => { + const { repo, chart } = params; - handler: async ({ params, query }) => ({ + return { response: await helmService.getChart( - params.repo, - params.chart, - query.get("version"), + repo, + chart, + query.get("version") ?? undefined, ), - }), + }; }), - - injectionToken: routeInjectionToken, }); export default getChartRouteInjectable; diff --git a/src/main/routes/helm/charts/get-chart-values-route.injectable.ts b/src/main/routes/helm/charts/get-chart-values-route.injectable.ts index 4eeeb263ec..57fd067ed5 100644 --- a/src/main/routes/helm/charts/get-chart-values-route.injectable.ts +++ b/src/main/routes/helm/charts/get-chart-values-route.injectable.ts @@ -2,32 +2,24 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import type { Route } from "../../../router/router"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { helmService } from "../../../helm/helm-service"; import { apiPrefix } from "../../../../common/vars"; +import { route } from "../../../router/route"; -const getChartRouteValuesInjectable = getInjectable({ +const getChartRouteValuesInjectable = getRouteInjectable({ id: "get-chart-route-values", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}/values`, - - handler: async ({ - params, - query, - }) => ({ - response: await helmService.getChartValues( - params.repo, - params.chart, - query.get("version"), - ), - }), - }), - - injectionToken: routeInjectionToken, + })(async ({ params, query }) => ({ + response: await helmService.getChartValues( + params.repo, + params.chart, + query.get("version") ?? undefined, + ), + })), }); export default getChartRouteValuesInjectable; diff --git a/src/main/routes/helm/charts/list-charts-route.injectable.ts b/src/main/routes/helm/charts/list-charts-route.injectable.ts index a1273ca7af..670cc9b889 100644 --- a/src/main/routes/helm/charts/list-charts-route.injectable.ts +++ b/src/main/routes/helm/charts/list-charts-route.injectable.ts @@ -2,25 +2,20 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import type { Route } from "../../../router/router"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { helmService } from "../../../helm/helm-service"; import { apiPrefix } from "../../../../common/vars"; +import { route } from "../../../router/route"; -const listChartsRouteInjectable = getInjectable({ +const listChartsRouteInjectable = getRouteInjectable({ id: "list-charts-route", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/v2/charts`, - - handler: async () => ({ - response: await helmService.listCharts(), - }), - }), - - injectionToken: routeInjectionToken, + })(async () => ({ + response: await helmService.listCharts(), + })), }); export default listChartsRouteInjectable; diff --git a/src/main/routes/helm/releases/delete-release-route.injectable.ts b/src/main/routes/helm/releases/delete-release-route.injectable.ts index 6487cc7ce8..b17a8ea5c3 100644 --- a/src/main/routes/helm/releases/delete-release-route.injectable.ts +++ b/src/main/routes/helm/releases/delete-release-route.injectable.ts @@ -3,32 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const deleteReleaseRouteInjectable = getInjectable({ +const deleteReleaseRouteInjectable = getRouteInjectable({ id: "delete-release-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "delete", path: `${apiPrefix}/v2/releases/{namespace}/{release}`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.deleteRelease( - cluster, - params.release, - params.namespace, - ), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params: { release, namespace }}) => ({ + response: await helmService.deleteRelease( + cluster, + release, + namespace, + ), + })), }); export default deleteReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/get-release-history-route.injectable.ts b/src/main/routes/helm/releases/get-release-history-route.injectable.ts index 5ceadb2695..a58f125a97 100644 --- a/src/main/routes/helm/releases/get-release-history-route.injectable.ts +++ b/src/main/routes/helm/releases/get-release-history-route.injectable.ts @@ -3,32 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const getReleaseRouteHistoryInjectable = getInjectable({ +const getReleaseRouteHistoryInjectable = getRouteInjectable({ id: "get-release-history-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/history`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.getReleaseHistory( - cluster, - params.release, - params.namespace, - ), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params }) => ({ + response: await helmService.getReleaseHistory( + cluster, + params.release, + params.namespace, + ), + })), }); export default getReleaseRouteHistoryInjectable; diff --git a/src/main/routes/helm/releases/get-release-route.injectable.ts b/src/main/routes/helm/releases/get-release-route.injectable.ts index e11ab64466..f2e8917ebf 100644 --- a/src/main/routes/helm/releases/get-release-route.injectable.ts +++ b/src/main/routes/helm/releases/get-release-route.injectable.ts @@ -3,32 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const getReleaseRouteInjectable = getInjectable({ +const getReleaseRouteInjectable = getRouteInjectable({ id: "get-release-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.getRelease( - cluster, - params.release, - params.namespace, - ), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params }) => ({ + response: await helmService.getRelease( + cluster, + params.release, + params.namespace, + ), + })), }); export default getReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/get-release-values-route.injectable.ts b/src/main/routes/helm/releases/get-release-values-route.injectable.ts index 92227042e7..6908257f5b 100644 --- a/src/main/routes/helm/releases/get-release-values-route.injectable.ts +++ b/src/main/routes/helm/releases/get-release-values-route.injectable.ts @@ -3,37 +3,27 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { getBoolean } from "../../../utils/parse-query"; import { contentTypes } from "../../../router/router-content-types"; +import { clusterRoute } from "../../../router/route"; -const getReleaseRouteValuesInjectable = getInjectable({ +const getReleaseRouteValuesInjectable = getRouteInjectable({ id: "get-release-values-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/values`, + })(async ({ cluster, params: { namespace, release }, query }) => ({ + response: await helmService.getReleaseValues(release, { + cluster, + namespace, + all: getBoolean(query, "all"), + }), - handler: async (request) => { - const { cluster, params: { namespace, release }, query } = request; - const all = getBoolean(query, "all"); - - return { - response: await helmService.getReleaseValues(release, { - cluster, - namespace, - all, - }), - - contentType: contentTypes.txt, - }; - }, - }), - - injectionToken: routeInjectionToken, + contentType: contentTypes.txt, + })), }); export default getReleaseRouteValuesInjectable; diff --git a/src/main/routes/helm/releases/install-chart-route.injectable.ts b/src/main/routes/helm/releases/install-chart-route.injectable.ts index 879e39c4ac..bb1d0c923c 100644 --- a/src/main/routes/helm/releases/install-chart-route.injectable.ts +++ b/src/main/routes/helm/releases/install-chart-route.injectable.ts @@ -3,34 +3,42 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; +import type { InstallChartArgs } from "../../../helm/helm-service"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import Joi from "joi"; +import { payloadValidatedClusterRoute } from "../../../router/route"; -interface InstallChartResponse { - log: string; - release: { name: string; namespace: string }; -} +const installChartArgsValidator = Joi.object({ + chart: Joi + .string() + .required(), + values: Joi + .object() + .required() + .unknown(true), + name: Joi + .string() + .required(), + namespace: Joi + .string() + .required(), + version: Joi + .string() + .required(), +}); -const installChartRouteInjectable = getInjectable({ +const installChartRouteInjectable = getRouteInjectable({ id: "install-chart-route", - instantiate: () : Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "post", path: `${apiPrefix}/v2/releases`, - - handler: async (request) => { - const { payload, cluster } = request; - - return { - response: await helmService.installChart(cluster, payload), - statusCode: 201, - }; - }, - }), - - injectionToken: routeInjectionToken, + payloadValidator: installChartArgsValidator, + })(async ({ payload, cluster }) => ({ + response: await helmService.installChart(cluster, payload), + statusCode: 201, + })), }); export default installChartRouteInjectable; diff --git a/src/main/routes/helm/releases/list-releases-route.injectable.ts b/src/main/routes/helm/releases/list-releases-route.injectable.ts index b73c618ff3..b57d646526 100644 --- a/src/main/routes/helm/releases/list-releases-route.injectable.ts +++ b/src/main/routes/helm/releases/list-releases-route.injectable.ts @@ -3,28 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const listReleasesRouteInjectable = getInjectable({ +const listReleasesRouteInjectable = getRouteInjectable({ id: "list-releases-route", - instantiate: (): Route> => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace?}`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.listReleases(cluster, params.namespace), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params }) => ({ + response: await helmService.listReleases(cluster, params.namespace), + })), }); export default listReleasesRouteInjectable; diff --git a/src/main/routes/helm/releases/rollback-release-route.injectable.ts b/src/main/routes/helm/releases/rollback-release-route.injectable.ts index 1880dc7e1e..36a92c2820 100644 --- a/src/main/routes/helm/releases/rollback-release-route.injectable.ts +++ b/src/main/routes/helm/releases/rollback-release-route.injectable.ts @@ -3,26 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import Joi from "joi"; +import { payloadValidatedClusterRoute } from "../../../router/route"; -const rollbackReleaseRouteInjectable = getInjectable({ +interface RollbackReleasePayload { + revision: number; +} + +const rollbackReleasePayloadValidator = Joi.object({ + revision: Joi + .number() + .required(), +}); + +const rollbackReleaseRouteInjectable = getRouteInjectable({ id: "rollback-release-route", - instantiate: (): Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "put", path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback`, - - handler: async (request) => { - const { cluster, params, payload } = request; - - await helmService.rollback(cluster, params.release, params.namespace, payload.revision); - }, + payloadValidator: rollbackReleasePayloadValidator, + })(async ({ cluster, params: { release, namespace }, payload }) => { + await helmService.rollback(cluster, release, namespace, payload.revision); }), - - injectionToken: routeInjectionToken, }); export default rollbackReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/update-release-route.injectable.ts b/src/main/routes/helm/releases/update-release-route.injectable.ts index f97dc1a2ad..0d3602f505 100644 --- a/src/main/routes/helm/releases/update-release-route.injectable.ts +++ b/src/main/routes/helm/releases/update-release-route.injectable.ts @@ -3,38 +3,39 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; +import type { UpdateChartArgs } from "../../../helm/helm-service"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { payloadValidatedClusterRoute } from "../../../router/route"; +import Joi from "joi"; -interface UpdateReleaseResponse { - log: string; - release: { name: string; namespace: string }; -} +const updateChartArgsValidator = Joi.object({ + chart: Joi + .string() + .required(), + version: Joi + .string() + .required(), + values: Joi + .object() + .unknown(true), +}); -const updateReleaseRouteInjectable = getInjectable({ +const updateReleaseRouteInjectable = getRouteInjectable({ id: "update-release-route", - instantiate: (): Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "put", path: `${apiPrefix}/v2/releases/{namespace}/{release}`, - - handler: async ({ + payloadValidator: updateChartArgsValidator, + })(async ({ cluster, params, payload }) => ({ + response: await helmService.updateRelease( cluster, - params, + params.release, + params.namespace, payload, - }) => ({ - response: await helmService.updateRelease( - cluster, - params.release, - params.namespace, - payload, - ), - }), - }), - - injectionToken: routeInjectionToken, + ), + })), }); export default updateReleaseRouteInjectable; diff --git a/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts b/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts index b0b2ae96e9..48bdc5f644 100644 --- a/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts +++ b/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts @@ -3,43 +3,88 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; import { apiPrefix } from "../../../common/vars"; -import type { Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import type { Cluster } from "../../../common/cluster/cluster"; import type { V1Secret } from "@kubernetes/client-node"; import { CoreV1Api } from "@kubernetes/client-node"; +import { clusterRoute } from "../../router/route"; -const getServiceAccountRouteInjectable = getInjectable({ +const getServiceAccountRouteInjectable = getRouteInjectable({ id: "get-service-account-route", - instantiate: (): Route> => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}`, + })(async ({ params, cluster }) => { + const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api); + const secretList = await client.listNamespacedSecret(params.namespace); - handler: async (request) => { - const { params, cluster } = request; - const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api); - const secretList = await client.listNamespacedSecret(params.namespace); + const secret = secretList.body.items.find(secret => { + const { annotations } = secret.metadata ?? {}; - const secret = secretList.body.items.find(secret => { - const { annotations } = secret.metadata; + return annotations?.["kubernetes.io/service-account.name"] === params.account; + }); - return annotations && annotations["kubernetes.io/service-account.name"] == params.account; - }); + if (!secret) { + return { + error: "No secret found", + statusCode: 404, + }; + } - return { response: generateKubeConfig(params.account, secret, cluster) }; - }, + const kubeconfig = generateKubeConfig(params.account, secret, cluster); + + if (!kubeconfig) { + return { + error: "No secret found", + statusCode: 404, + }; + } + + return { + response: kubeconfig, + }; }), - - injectionToken: routeInjectionToken, }); export default getServiceAccountRouteInjectable; -function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { - const tokenData = Buffer.from(secret.data["token"], "base64"); +interface ServiceAccountKubeConfig { + apiVersion: string; + kind: string; + clusters: { + name: string; + cluster: { + server: string; + "certificate-authority-data": string; + }; + }[]; + users: { + name: string; + user: { + token: string; + }; + }[]; + contexts: { + name: string; + context: { + user: string; + cluster: string; + namespace: string | undefined; + }; + }[]; + "current-context": string; +} + +function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster): ServiceAccountKubeConfig | undefined { + if (!secret.data || !secret.metadata) { + return undefined; + } + + const { token, "ca.crt": caCrt } = secret.data; + + const tokenData = Buffer.from(token, "base64"); return { "apiVersion": "v1", @@ -49,7 +94,7 @@ function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster "name": cluster.contextName, "cluster": { "server": cluster.apiUrl, - "certificate-authority-data": secret.data["ca.crt"], + "certificate-authority-data": caCrt, }, }, ], diff --git a/src/main/routes/metrics/add-metrics-route.injectable.ts b/src/main/routes/metrics/add-metrics-route.injectable.ts index 470fecd56c..8bd6ad2c72 100644 --- a/src/main/routes/metrics/add-metrics-route.injectable.ts +++ b/src/main/routes/metrics/add-metrics-route.injectable.ts @@ -3,21 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; import { apiPrefix } from "../../../common/vars"; -import type { LensApiRequest, Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import type { ClusterPrometheusMetadata } from "../../../common/cluster-types"; import { ClusterMetadataKey } from "../../../common/cluster-types"; import logger from "../../logger"; import type { Cluster } from "../../../common/cluster/cluster"; -import { getMetrics } from "../../k8s-request"; import type { IMetricsQuery } from "./metrics-query"; +import { clusterRoute } from "../../router/route"; +import { isObject } from "lodash"; +import { isRequestError } from "../../../common/utils"; +import type { GetMetrics } from "../../get-metrics.injectable"; +import getMetricsInjectable from "../../get-metrics.injectable"; // This is used for backoff retry tracking. const ATTEMPTS = [false, false, false, false, true]; -async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record): Promise { +const loadMetricsFor = (getMetrics: GetMetrics) => async (promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record): Promise => { const queries = promQueries.map(p => p.trim()); const loaders = new Map>(); @@ -27,8 +29,13 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa try { return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); } catch (error) { - if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { - logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); + if (isRequestError(error)) { + if (lastAttempt || (error.statusCode && error.statusCode >= 400 && error.statusCode < 500)) { + logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); + throw new Error("Metrics not available"); + } + } else { + logger.error("[Metrics]: metrics not available", error); throw new Error("Metrics not available"); } @@ -41,69 +48,71 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa } return Promise.all(queries.map(loadMetric)); -} - -const addMetricsRoute = async ({ cluster, payload, query }: LensApiRequest) => { - const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); - const prometheusMetadata: ClusterPrometheusMetadata = {}; - - try { - const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails(); - - prometheusMetadata.provider = provider?.id; - prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; - - if (!prometheusPath) { - prometheusMetadata.success = false; - - return { response: {}}; - } - - // return data in same structure as query - if (typeof payload === "string") { - const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); - - return { response: data }; - } - - if (Array.isArray(payload)) { - const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); - - return { response: data }; - } - - const queries = Object.entries>(payload) - .map(([queryName, queryOpts]) => ( - provider.getQuery(queryOpts, queryName) - )); - - const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); - const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); - - prometheusMetadata.success = true; - - return { response: data }; - } catch (error) { - prometheusMetadata.success = false; - - logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error); - - return { response: {}}; - } finally { - cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; - } }; -const addMetricsRouteInjectable = getInjectable({ +const addMetricsRouteInjectable = getRouteInjectable({ id: "add-metrics-route", - instantiate: (): Route => ({ + instantiate: (di) => clusterRoute({ method: "post", path: `${apiPrefix}/metrics`, - handler: addMetricsRoute, - }), + })(async ({ cluster, payload, query }) => { + const getMetrics = di.inject(getMetricsInjectable); + const loadMetrics = loadMetricsFor(getMetrics); - injectionToken: routeInjectionToken, + const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); + const prometheusMetadata: ClusterPrometheusMetadata = {}; + + try { + const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails(); + + prometheusMetadata.provider = provider?.id; + prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; + + if (!prometheusPath) { + prometheusMetadata.success = false; + + return { response: {}}; + } + + // return data in same structure as query + if (typeof payload === "string") { + const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); + + return { response: data }; + } + + if (Array.isArray(payload)) { + const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); + + return { response: data }; + } + + if (isObject(payload)) { + const queries = Object.entries(payload as Record>) + .map(([queryName, queryOpts]) => ( + provider.getQuery(queryOpts, queryName) + )); + + const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); + const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); + + prometheusMetadata.success = true; + + return { response: data }; + } + + return { response: {}}; + } catch (error) { + prometheusMetadata.success = false; + + logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error); + + return { response: {}}; + } finally { + cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; + } + }), }); export default addMetricsRouteInjectable; diff --git a/src/main/routes/metrics/get-metric-providers-route.injectable.ts b/src/main/routes/metrics/get-metric-providers-route.injectable.ts index 8fcba9d984..22b2fbf11b 100644 --- a/src/main/routes/metrics/get-metric-providers-route.injectable.ts +++ b/src/main/routes/metrics/get-metric-providers-route.injectable.ts @@ -3,32 +3,29 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; import { apiPrefix } from "../../../common/vars"; -import type { Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; -import { PrometheusProviderRegistry } from "../../prometheus"; -import type { MetricProviderInfo } from "../../../common/k8s-api/endpoints/metrics.api"; +import { getRouteInjectable } from "../../router/router.injectable"; +import { route } from "../../router/route"; +import prometheusProviderRegistryInjectable from "../../prometheus/prometheus-provider-registry.injectable"; -const getMetricProvidersRouteInjectable = getInjectable({ +const getMetricProvidersRouteInjectable = getRouteInjectable({ id: "get-metric-providers-route", - instantiate: (): Route => ({ - method: "get", - path: `${apiPrefix}/metrics/providers`, + instantiate: (di) => { + const prometheusProviderRegistry = di.inject(prometheusProviderRegistryInjectable); - handler: () => { - const providers: MetricProviderInfo[] = []; - - for (const { name, id, isConfigurable } of PrometheusProviderRegistry.getInstance().providers.values()) { - providers.push({ name, id, isConfigurable }); - } - - return { response: providers }; - }, - }), - - injectionToken: routeInjectionToken, + return route({ + method: "get", + path: `${apiPrefix}/metrics/providers`, + })(() => ({ + response: Array.from( + prometheusProviderRegistry + .providers + .values(), + ({ name, id, isConfigurable }) => ({ name, id, isConfigurable }), + ), + })); + }, }); export default getMetricProvidersRouteInjectable; diff --git a/src/main/routes/port-forward/functionality/port-forward.ts b/src/main/routes/port-forward/functionality/port-forward.ts index 497176ebf4..eafe465972 100644 --- a/src/main/routes/port-forward/functionality/port-forward.ts +++ b/src/main/routes/port-forward/functionality/port-forward.ts @@ -7,8 +7,9 @@ import { getPortFrom } from "../../../utils/get-port"; import type { ChildProcessWithoutNullStreams } from "child_process"; import { spawn } from "child_process"; import * as tcpPortUsed from "tcp-port-used"; +import { TypedRegEx } from "typed-regex"; -const internalPortRegex = /^forwarding from (?

.+) ->/i; +const internalPortRegex = TypedRegEx("^forwarding from (?
.+) ->", "i"); export interface PortForwardArgs { clusterId: string; @@ -36,7 +37,7 @@ export class PortForward { )); } - public process: ChildProcessWithoutNullStreams; + public process?: ChildProcessWithoutNullStreams; public clusterId: string; public kind: string; public namespace: string; @@ -98,6 +99,6 @@ export class PortForward { } public async stop() { - this.process.kill(); + this.process?.kill(); } } diff --git a/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts b/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts index a5b83edbf6..769046c804 100644 --- a/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts +++ b/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts @@ -2,36 +2,33 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { LensApiRequest, Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { PortForward } from "./functionality/port-forward"; +import { clusterRoute } from "../../router/route"; -const getCurrentPortForward = async (request: LensApiRequest) => { - const { params, query, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - const portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, - }); - - return { response: { port: portForward?.forwardPort ?? null }}; -}; - -const getCurrentPortForwardRouteInjectable = getInjectable({ +const getCurrentPortForwardRouteInjectable = getRouteInjectable({ id: "get-current-port-forward-route", - instantiate: (): Route<{ port: number }> => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, - handler: getCurrentPortForward, - }), + })(async ({ params, query, cluster }) => { + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); - injectionToken: routeInjectionToken, + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); + + return { + response: { + port: portForward?.forwardPort ?? null, + }, + }; + }), }); export default getCurrentPortForwardRouteInjectable; diff --git a/src/main/routes/port-forward/start-port-forward-route.injectable.ts b/src/main/routes/port-forward/start-port-forward-route.injectable.ts index 50afbd2575..ba7a857255 100644 --- a/src/main/routes/port-forward/start-port-forward-route.injectable.ts +++ b/src/main/routes/port-forward/start-port-forward-route.injectable.ts @@ -2,97 +2,90 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { LensApiRequest, Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; -import type { PortForwardArgs } from "./functionality/port-forward"; import { PortForward } from "./functionality/port-forward"; import logger from "../../logger"; import createPortForwardInjectable from "./functionality/create-port-forward.injectable"; +import { clusterRoute } from "../../router/route"; -interface Dependencies { - createPortForward: (pathToKubeConfig: string, args: PortForwardArgs) => PortForward; -} +const startPortForwardRouteInjectable = getRouteInjectable({ + id: "start-current-port-forward-route", -const startPortForward = ({ createPortForward }: Dependencies) => async (request: LensApiRequest) => { - const { params, query, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); + instantiate: (di) => { + const createPortForward = di.inject(createPortForwardInjectable); - try { - let portForward = PortForward.getPortforward({ - clusterId: cluster.id, - kind: resourceType, - name: resourceName, - namespace, - port, - forwardPort, - }); + return clusterRoute({ + method: "post", + path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, + })(async ({ params, query, cluster }) => { + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); - if (!portForward) { - logger.info( - `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, - ); - - const thePort = - 0 < forwardPort && forwardPort < 65536 ? forwardPort : 0; - - portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { - clusterId: cluster.id, - kind: resourceType, - namespace, - name: resourceName, - port, - forwardPort: thePort, - }); - - const started = await portForward.start(); - - if (!started) { - logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { + try { + let portForward = PortForward.getPortforward({ + clusterId: cluster.id, + kind: resourceType, + name: resourceName, namespace, port, - resourceType, - resourceName, + forwardPort, }); + if (!portForward) { + logger.info( + `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, + ); + + const thePort = 0 < forwardPort && forwardPort < 65536 + ? forwardPort + : 0; + + portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { + clusterId: cluster.id, + kind: resourceType, + namespace, + name: resourceName, + port, + forwardPort: thePort, + }); + + const started = await portForward.start(); + + if (!started) { + logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { + namespace, + port, + resourceType, + resourceName, + }); + + return { + error: { + message: `Failed to forward port ${port} to ${ + thePort ? forwardPort : "random port" + }`, + }, + }; + } + } + + return { response: { port: portForward.forwardPort }}; + } catch (error) { + logger.error( + `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, + { namespace, port, resourceType, resourceName }, + ); + return { error: { - message: `Failed to forward port ${port} to ${ - thePort ? forwardPort : "random port" - }`, + message: `Failed to forward port ${port}`, }, }; } - } - - return { response: { port: portForward.forwardPort }}; - } catch (error) { - logger.error( - `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, - { namespace, port, resourceType, resourceName }, - ); - - return { - error: { - message: `Failed to forward port ${port}`, - }, - }; - } -}; - -const startPortForwardRouteInjectable = getInjectable({ - id: "start-current-port-forward-route", - - instantiate: (di): Route<{ port: number }> => ({ - method: "post", - path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, - handler: startPortForward({ createPortForward: di.inject(createPortForwardInjectable) }), - }), - - injectionToken: routeInjectionToken, + }); + }, }); export default startPortForwardRouteInjectable; diff --git a/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts b/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts index b9fce502c1..6ae23af9c2 100644 --- a/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts +++ b/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts @@ -2,50 +2,42 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { LensApiRequest, Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { PortForward } from "./functionality/port-forward"; import logger from "../../logger"; +import { clusterRoute } from "../../router/route"; -const stopCurrentPortForward = async (request: LensApiRequest) => { - const { params, query, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - const portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, - }); - - try { - await portForward.stop(); - - return { response: { status: true }}; - } catch (error) { - logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); - - return { - error: { - message: `error stopping a forward port ${port}`, - }, - }; - - } -}; - -const stopCurrentPortForwardRouteInjectable = getInjectable({ +const stopCurrentPortForwardRouteInjectable = getRouteInjectable({ id: "stop-current-port-forward-route", - instantiate: (): Route<{ status: boolean }> => ({ + instantiate: () => clusterRoute({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, - handler: stopCurrentPortForward, - }), + })(async ({ params, query, cluster }) => { + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); - injectionToken: routeInjectionToken, + try { + await portForward?.stop(); + + return { response: { status: true }}; + } catch (error) { + logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); + + return { + error: { + message: `error stopping a forward port ${port}`, + }, + }; + + } + }), }); export default stopCurrentPortForwardRouteInjectable; diff --git a/src/main/routes/resource-applier/apply-resource-route.injectable.ts b/src/main/routes/resource-applier/apply-resource-route.injectable.ts index b1db11cfa3..45f484fd3f 100644 --- a/src/main/routes/resource-applier/apply-resource-route.injectable.ts +++ b/src/main/routes/resource-applier/apply-resource-route.injectable.ts @@ -2,25 +2,20 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { ResourceApplier } from "../../resource-applier"; +import { clusterRoute } from "../../router/route"; -const applyResourceRouteInjectable = getInjectable({ +const applyResourceRouteInjectable = getRouteInjectable({ id: "apply-resource-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "post", path: `${apiPrefix}/stack`, - - handler: async ({ cluster, payload }) => ({ - response: await new ResourceApplier(cluster).apply(payload), - }), - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, payload }) => ({ + response: await new ResourceApplier(cluster).apply(payload), + })), }); export default applyResourceRouteInjectable; diff --git a/src/main/routes/resource-applier/patch-resource-route.injectable.ts b/src/main/routes/resource-applier/patch-resource-route.injectable.ts index 2ac26db726..53506d6bb0 100644 --- a/src/main/routes/resource-applier/patch-resource-route.injectable.ts +++ b/src/main/routes/resource-applier/patch-resource-route.injectable.ts @@ -2,30 +2,56 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { ResourceApplier } from "../../resource-applier"; +import { payloadValidatedClusterRoute } from "../../router/route"; +import Joi from "joi"; +import type { Patch } from "rfc6902"; -const patchResourceRouteInjectable = getInjectable({ +interface PatchResourcePayload { + name: string; + kind: string; + patch: Patch; + ns?: string; +} + +const patchResourcePayloadValidator = Joi.object({ + name: Joi + .string() + .required(), + kind: Joi + .string() + .required(), + ns: Joi + .string() + .optional(), + patch: Joi + .array() + .allow( + Joi.object({ + op: Joi + .string() + .required(), + }).unknown(true), + ), +}); + +const patchResourceRouteInjectable = getRouteInjectable({ id: "patch-resource-route", - instantiate: (): Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "patch", path: `${apiPrefix}/stack`, - - handler: async ({ cluster, payload }) => ({ - response: await new ResourceApplier(cluster).patch( - payload.name, - payload.kind, - payload.patch, - payload.ns, - ), - }), - }), - - injectionToken: routeInjectionToken, + payloadValidator: patchResourcePayloadValidator, + })(async ({ cluster, payload }) => ({ + response: await new ResourceApplier(cluster).patch( + payload.name, + payload.kind, + payload.patch, + payload.ns, + ), + })), }); export default patchResourceRouteInjectable; diff --git a/src/main/routes/static-file-route.injectable.ts b/src/main/routes/static-file-route.injectable.ts index 9e90e23eef..86fa4b2fc5 100644 --- a/src/main/routes/static-file-route.injectable.ts +++ b/src/main/routes/static-file-route.injectable.ts @@ -2,13 +2,11 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import type { LensApiRequest, Route } from "../router/router"; import type { SupportedFileExtension } from "../router/router-content-types"; import { contentTypes } from "../router/router-content-types"; import logger from "../logger"; -import { routeInjectionToken } from "../router/router.injectable"; -import { appName, publicPath, staticFilesDirectory } from "../../common/vars"; +import { getRouteInjectable } from "../router/router.injectable"; +import { appName, publicPath } from "../../common/vars"; import path from "path"; import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; import httpProxy from "http-proxy"; @@ -18,24 +16,27 @@ import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injec import type { JoinPaths } from "../../common/path/join-paths.injectable"; import joinPathsInjectable from "../../common/path/join-paths.injectable"; import { webpackDevServerPort } from "../../../webpack/vars"; +import type { LensApiRequest, RouteResponse } from "../router/route"; +import { route } from "../router/route"; +import staticFilesDirectoryInjectable from "../../common/vars/static-files-directory.injectable"; interface ProductionDependencies { readFileBuffer: (path: string) => Promise; getAbsolutePath: GetAbsolutePath; joinPaths: JoinPaths; + staticFilesDirectory: string; } const handleStaticFileInProduction = - ({ readFileBuffer, getAbsolutePath, joinPaths }: ProductionDependencies) => - async ({ params }: LensApiRequest) => { - const staticPath = getAbsolutePath(staticFilesDirectory); + ({ readFileBuffer, getAbsolutePath, joinPaths, staticFilesDirectory }: ProductionDependencies) => + async ({ params }: LensApiRequest<"/{path*}">): Promise> => { let filePath = params.path; for (let retryCount = 0; retryCount < 5; retryCount += 1) { - const asset = joinPaths(staticPath, filePath); + const asset = joinPaths(staticFilesDirectory, filePath); const normalizedFilePath = getAbsolutePath(asset); - if (!normalizedFilePath.startsWith(staticPath)) { + if (!normalizedFilePath.startsWith(staticFilesDirectory)) { return { statusCode: 404 }; } @@ -49,7 +50,7 @@ const handleStaticFileInProduction = return { response: await readFileBuffer(asset), contentType }; } catch (err) { if (retryCount > 5) { - logger.error("handleStaticFile:", err.toString()); + logger.error("handleStaticFile:", String(err)); return { statusCode: 404 }; } @@ -67,10 +68,8 @@ interface DevelopmentDependencies { const handleStaticFileInDevelopment = ({ proxy }: DevelopmentDependencies) => - (apiReq: LensApiRequest) => { - const { req, res } = apiReq.raw; - - if (req.url === "/" || !req.url.startsWith("/build/")) { + ({ raw: { req, res }}: LensApiRequest<"/{path*}">): RouteResponse => { + if (req.url === "/" || !req.url?.startsWith("/build/")) { req.url = `${publicPath}/${appName}.html`; } @@ -81,25 +80,32 @@ const handleStaticFileInDevelopment = return { proxy }; }; -const staticFileRouteInjectable = getInjectable({ +const staticFileRouteInjectable = getRouteInjectable({ id: "static-file-route", - instantiate: (di): Route => { + instantiate: (di) => { const isDevelopment = di.inject(isDevelopmentInjectable); const readFileBuffer = di.inject(readFileBufferInjectable); const getAbsolutePath = di.inject(getAbsolutePathInjectable); const joinPaths = di.inject(joinPathsInjectable); + const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); - return { + return route({ method: "get", path: `/{path*}`, - handler: isDevelopment - ? handleStaticFileInDevelopment({ proxy: httpProxy.createProxy() }) - : handleStaticFileInProduction({ readFileBuffer, getAbsolutePath, joinPaths }), - }; + })( + isDevelopment + ? handleStaticFileInDevelopment({ + proxy: httpProxy.createProxy(), + }) + : handleStaticFileInProduction({ + readFileBuffer, + getAbsolutePath, + joinPaths, + staticFilesDirectory, + }), + ); }, - - injectionToken: routeInjectionToken, }); export default staticFileRouteInjectable; diff --git a/src/main/routes/versions/get-version-route.injectable.ts b/src/main/routes/versions/get-version-route.injectable.ts index 357c09f22d..aa846c171c 100644 --- a/src/main/routes/versions/get-version-route.injectable.ts +++ b/src/main/routes/versions/get-version-route.injectable.ts @@ -2,22 +2,21 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import type { Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import { getAppVersion } from "../../../common/utils"; +import { route } from "../../router/route"; -const getVersionRouteInjectable = getInjectable({ +const getVersionRouteInjectable = getRouteInjectable({ id: "get-version-route", - instantiate: (): Route<{ version: string }> => ({ + instantiate: () => route({ method: "get", path: `/version`, - - handler: () => ({ response: { version: getAppVersion() }}), - }), - - injectionToken: routeInjectionToken, + })(() => ({ + response: { + version: getAppVersion(), + }, + })), }); export default getVersionRouteInjectable; diff --git a/src/main/shell-session/local-shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session/local-shell-session.ts index 314f8700ec..9f42c7f242 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.ts @@ -15,7 +15,7 @@ import { baseBinariesDir } from "../../../common/vars"; export class LocalShellSession extends ShellSession { ShellType = "shell"; - constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record) => Record, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) { + constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record) => Record, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) { super(kubectl, websocket, cluster, terminalId); } @@ -34,9 +34,14 @@ export class LocalShellSession extends ShellSession { env = this.shellEnvModify(this.cluster.id, env); const shell = env.PTYSHELL; + + if (!shell) { + throw new Error("PTYSHELL is not defined with the environment"); + } + const args = await this.getShellArgs(shell); - await this.openShellProcess(env.PTYSHELL, args, env); + await this.openShellProcess(shell, args, env); } protected async getShellArgs(shell: string): Promise { @@ -45,11 +50,11 @@ export class LocalShellSession extends ShellSession { switch(path.basename(shell)) { case "powershell.exe": - return ["-NoExit", "-command", `& {$Env:PATH="${baseBinariesDir.get()};${kubectlPathDir};$Env:PATH"}`]; + return ["-NoExit", "-command", `& {$Env:PATH="${kubectlPathDir};${baseBinariesDir.get()};$Env:PATH"}`]; case "bash": return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")]; case "fish": - return ["--login", "--init-command", `export PATH="${baseBinariesDir.get()}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`]; + return ["--login", "--init-command", `export PATH="${kubectlPathDir}:${baseBinariesDir.get()}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`]; case "zsh": return ["--login"]; default: diff --git a/src/main/shell-session/node-shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session/node-shell-session.ts index 8c5861bff5..4c619a612c 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.ts @@ -5,22 +5,21 @@ import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; -import * as k8s from "@kubernetes/client-node"; +import { Watch, CoreV1Api } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../../../common/cluster/cluster"; import { ShellOpenError, ShellSession } from "../shell-session"; -import { get } from "lodash"; -import { Node, NodesApi } from "../../../common/k8s-api/endpoints"; +import { get, once } from "lodash"; +import { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; import logger from "../../logger"; -import { TerminalChannels } from "../../../renderer/api/terminal-api"; import type { Kubectl } from "../../kubectl/kubectl"; +import { TerminalChannels } from "../../../common/terminal/channels"; export class NodeShellSession extends ShellSession { ShellType = "node-shell"; protected readonly podName = `node-shell-${uuid()}`; - protected kc: KubeConfig; protected readonly cwd: string | undefined = undefined; @@ -29,29 +28,49 @@ export class NodeShellSession extends ShellSession { } public async open() { - this.kc = await this.cluster.getProxyKubeconfig(); + const kc = await this.cluster.getProxyKubeconfig(); + const coreApi = kc.makeApiClient(CoreV1Api); const shell = await this.kubectl.getPath(); + const cleanup = once(() => { + coreApi + .deleteNamespacedPod(this.podName, "kube-system") + .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); + }); + + this.websocket.once("close", cleanup); + try { - await this.createNodeShellPod(); - await this.waitForRunningPod(); + await this.createNodeShellPod(coreApi); + await this.waitForRunningPod(kc); } catch (error) { - this.deleteNodeShellPod(); + cleanup(); + this.send({ type: TerminalChannels.STDOUT, - data: `Error occurred: ${get(error, "response.body.message", error?.toString() || "unknown error")}`, + data: `Error occurred: ${get(error, "response.body.message", error ? String(error) : "unknown error")}`, }); - throw new ShellOpenError("failed to create node pod", error); + throw new ShellOpenError( + "failed to create node pod", + error instanceof Error + ? { cause: error } + : undefined, + ); } const env = await this.getCachedShellEnv(); const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"]; - const nodeApi = new NodesApi({ + const nodeApi = new NodeApi({ objectConstructor: Node, request: KubeJsonApi.forCluster(this.cluster.id), }); const node = await nodeApi.get({ name: this.nodeName }); + + if (!node) { + throw new Error(`No node with name=${this.nodeName} found`); + } + const nodeOs = node.getOperatingSystem(); switch (nodeOs) { @@ -69,16 +88,14 @@ export class NodeShellSession extends ShellSession { await this.openShellProcess(shell, args, env); } - protected createNodeShellPod() { + protected createNodeShellPod(coreApi: CoreV1Api) { const imagePullSecrets = this.cluster.imagePullSecret ? [{ name: this.cluster.imagePullSecret, }] : undefined; - return this - .kc - .makeApiClient(k8s.CoreV1Api) + return coreApi .createNamespacedPod("kube-system", { metadata: { name: this.podName, @@ -109,11 +126,11 @@ export class NodeShellSession extends ShellSession { }); } - protected waitForRunningPod(): Promise { + protected waitForRunningPod(kc: KubeConfig): Promise { logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`); return new Promise((resolve, reject) => { - new k8s.Watch(this.kc) + new Watch(kc) .watch(`/api/v1/namespaces/kube-system/pods`, {}, // callback is called for each received object. @@ -146,17 +163,4 @@ export class NodeShellSession extends ShellSession { }); }); } - - protected exit() { - super.exit(); - this.deleteNodeShellPod(); - } - - protected deleteNodeShellPod() { - this - .kc - .makeApiClient(k8s.CoreV1Api) - .deleteNamespacedPod(this.podName, "kube-system") - .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); - } } diff --git a/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts b/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts index f60631e554..bf6a290052 100644 --- a/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts +++ b/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts @@ -6,19 +6,21 @@ import type { IComputedValue } from "mobx"; import { computed } from "mobx"; import type { ClusterId } from "../../../common/cluster-types"; +import { isDefined } from "../../../common/utils"; import type { LensMainExtension } from "../../../extensions/lens-main-extension"; -import { catalogEntityRegistry } from "../../catalog"; - +import type { CatalogEntityRegistry } from "../../catalog"; + interface Dependencies { extensions: IComputedValue; + catalogEntityRegistry: CatalogEntityRegistry; } - -export const terminalShellEnvModify = ({ extensions }: Dependencies) => - (clusterId: ClusterId, env: Record) => { + +export const terminalShellEnvModify = ({ extensions, catalogEntityRegistry }: Dependencies) => + (clusterId: ClusterId, env: Record) => { const terminalShellEnvModifiers = computed(() => ( extensions.get() .map((extension) => extension.terminalShellEnvModifier) - .filter(Boolean) + .filter(isDefined) )) .get(); @@ -26,7 +28,7 @@ export const terminalShellEnvModify = ({ extensions }: Dependencies) => return env; } - const entity = catalogEntityRegistry.getById(clusterId); + const entity = catalogEntityRegistry.findById(clusterId); if (entity) { const ctx = { catalogEntity: entity }; diff --git a/src/main/shell-session/shell-env-modifier/terminal-shell-env-modify.injectable.ts b/src/main/shell-session/shell-env-modifier/terminal-shell-env-modify.injectable.ts index c9d345c3b2..7d8cb37a1c 100644 --- a/src/main/shell-session/shell-env-modifier/terminal-shell-env-modify.injectable.ts +++ b/src/main/shell-session/shell-env-modifier/terminal-shell-env-modify.injectable.ts @@ -6,6 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; import { terminalShellEnvModify } from "./terminal-shell-env-modifiers"; +import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable"; const terminalShellEnvModifyInjectable = getInjectable({ id: "terminal-shell-env-modify", @@ -13,6 +14,7 @@ const terminalShellEnvModifyInjectable = getInjectable({ instantiate: (di) => terminalShellEnvModify({ extensions: di.inject(mainExtensionsInjectable), + catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), }), }); diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 831c24bc02..42286ee24d 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -16,14 +16,13 @@ import { UserStore } from "../../common/user-store"; import * as pty from "node-pty"; import { appEventBus } from "../../common/app-event-bus/event-bus"; import logger from "../logger"; -import type { TerminalMessage } from "../../renderer/api/terminal-api"; -import { TerminalChannels } from "../../renderer/api/terminal-api"; -import { deserialize, serialize } from "v8"; import { stat } from "fs/promises"; +import { getOrInsertWith } from "../../common/utils"; +import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; export class ShellOpenError extends Error { - constructor(message: string, public cause: Error) { - super(`${message}: ${cause}`); + constructor(message: string, options?: ErrorOptions) { + super(`${message}`, options); this.name = this.constructor.name; Error.captureStackTrace(this); } @@ -106,10 +105,10 @@ export enum WebSocketCloseEvent { } export abstract class ShellSession { - abstract ShellType: string; + abstract readonly ShellType: string; - private static shellEnvs = new Map>(); - private static processes = new Map(); + private static readonly shellEnvs = new Map>(); + private static readonly processes = new Map(); /** * Kill all remaining shell backing processes. Should be called when about to @@ -128,45 +127,42 @@ export abstract class ShellSession { } protected running = false; - protected kubectlBinDirP: Promise; - protected kubeconfigPathP: Promise; + protected readonly kubectlBinDirP: Promise; + protected readonly kubeconfigPathP: Promise; protected readonly terminalId: string; protected abstract get cwd(): string | undefined; - protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { + protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { const resume = ShellSession.processes.has(this.terminalId); - - if (!resume) { - ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, { + const shellProcess = getOrInsertWith(ShellSession.processes, this.terminalId, () => ( + pty.spawn(shell, args, { rows: 30, cols: 80, cwd, - env, + env: env as Record, name: "xterm-256color", // TODO: Something else is broken here so we need to force the use of winPty on windows useConpty: false, - })); - } - - const shellProcess = ShellSession.processes.get(this.terminalId); + }) + )); logger.info(`[SHELL-SESSION]: PTY for ${this.terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`); return { shellProcess, resume }; } - constructor(protected kubectl: Kubectl, protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { + constructor(protected readonly kubectl: Kubectl, protected readonly websocket: WebSocket, protected readonly cluster: Cluster, terminalId: string) { this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); this.terminalId = `${cluster.id}:${terminalId}`; } protected send(message: TerminalMessage): void { - this.websocket.send(serialize(message)); + this.websocket.send(JSON.stringify(message)); } - protected async getCwd(env: Record): Promise { + protected async getCwd(env: Record): Promise { const cwdOptions = [this.cwd]; if (isWindows) { @@ -207,7 +203,7 @@ export abstract class ShellSession { return "."; // Always valid } - protected async openShellProcess(shell: string, args: string[], env: Record) { + protected async openShellProcess(shell: string, args: string[], env: Record) { const cwd = await this.getCwd(env); const { shellProcess, resume } = this.ensureShellProcess(shell, args, env, cwd); @@ -234,17 +230,19 @@ export abstract class ShellSession { }); this.websocket - .on("message", (data: string | Uint8Array) => { + .on("message", (rawData: unknown): void => { if (!this.running) { return void logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`); } - if (typeof data === "string") { - return void logger.silly(`[SHELL-SESSION]: Received message from ${this.terminalId}`, { data }); + if (!(rawData instanceof Buffer)) { + return void logger.error(`[SHELL-SESSION]: Received message non-buffer message.`, { rawData }); } + const data = rawData.toString(); + try { - const message: TerminalMessage = deserialize(data); + const message: TerminalMessage = JSON.parse(data); switch (message.type) { case TerminalChannels.STDIN: @@ -253,6 +251,9 @@ export abstract class ShellSession { case TerminalChannels.RESIZE: shellProcess.resize(message.data.width, message.data.height); break; + case TerminalChannels.PING: + logger.silly(`[SHELL-SESSION]: ${this.terminalId} ping!`); + break; default: logger.warn(`[SHELL-SESSION]: unknown or unhandleable message type for ${this.terminalId}`, message); break; @@ -261,7 +262,7 @@ export abstract class ShellSession { logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error); } }) - .on("close", code => { + .once("close", code => { logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() }); const stopShellSession = this.running @@ -313,7 +314,7 @@ export abstract class ShellSession { protected async getShellEnv() { const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); - const pathStr = [...this.getPathEntries(), await this.kubectlBinDirP, process.env.PATH].join(path.delimiter); + const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), process.env.PATH].join(path.delimiter); const shell = UserStore.getInstance().resolvedShell; delete env.DEBUG; // don't pass DEBUG into shells diff --git a/src/main/start-main-application/lens-window/application-window/application-window-state.injectable.ts b/src/main/start-main-application/lens-window/application-window/application-window-state.injectable.ts new file mode 100644 index 0000000000..cb59ee2be2 --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/application-window-state.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import windowStateKeeper from "electron-window-state"; + +interface WindowStateConfiguration { + id: string; + defaultHeight: number; + defaultWidth: number; +} + +const applicationWindowStateInjectable = getInjectable({ + id: "application-window-state", + + instantiate: (di, { id, defaultHeight, defaultWidth }) => windowStateKeeper({ + defaultHeight, + defaultWidth, + file: `window-state-for-${id}.json`, + }), + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, { id }: WindowStateConfiguration) => id, + }), +}); + +export default applicationWindowStateInjectable; diff --git a/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts b/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts new file mode 100644 index 0000000000..c63191c71c --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { lensWindowInjectionToken } from "./lens-window-injection-token"; +import createLensWindowInjectable from "./create-lens-window.injectable"; +import lensProxyPortInjectable from "../../../lens-proxy/lens-proxy-port.injectable"; +import isMacInjectable from "../../../../common/vars/is-mac.injectable"; +import appNameInjectable from "../../../app-paths/app-name/app-name.injectable"; +import appEventBusInjectable from "../../../../common/app-event-bus/app-event-bus.injectable"; +import { delay } from "../../../../common/utils"; +import { bundledExtensionsLoaded } from "../../../../common/ipc/extension-handling"; +import ipcMainInjectable from "../../../utils/channel/ipc-main/ipc-main.injectable"; + +const applicationWindowInjectable = getInjectable({ + id: "application-window", + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + const isMac = di.inject(isMacInjectable); + const applicationName = di.inject(appNameInjectable); + const appEventBus = di.inject(appEventBusInjectable); + const ipcMain = di.inject(ipcMainInjectable); + const lensProxyPort = di.inject(lensProxyPortInjectable); + + return createLensWindow({ + id: "only-application-window", + title: applicationName, + defaultHeight: 900, + defaultWidth: 1440, + getContentSource: () => ({ + url: `http://localhost:${lensProxyPort.get()}`, + }), + resizable: true, + windowFrameUtilitiesAreShown: isMac, + titleBarStyle: isMac ? "hiddenInset" : "hidden", + centered: false, + onFocus: () => { + appEventBus.emit({ name: "app", action: "focus" }); + }, + onBlur: () => { + appEventBus.emit({ name: "app", action: "blur" }); + }, + onDomReady: () => { + appEventBus.emit({ name: "app", action: "dom-ready" }); + }, + beforeOpen: async () => { + const viewHasLoaded = new Promise((resolve) => { + ipcMain.once(bundledExtensionsLoaded, () => resolve()); + }); + + await viewHasLoaded; + await delay(50); // wait just a bit longer to let the first round of rendering happen + }, + }); + }, + + injectionToken: lensWindowInjectionToken, +}); + +export default applicationWindowInjectable; diff --git a/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts b/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts new file mode 100644 index 0000000000..b0325c538c --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts @@ -0,0 +1,197 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../../../../common/logger.injectable"; +import applicationWindowStateInjectable from "./application-window-state.injectable"; +import { BrowserWindow } from "electron"; +import { openBrowser } from "../../../../common/utils"; +import sendToChannelInElectronBrowserWindowInjectable from "./send-to-channel-in-electron-browser-window.injectable"; +import type { LensWindow } from "./create-lens-window.injectable"; +import type { RequireExactlyOne } from "type-fest"; + +export type ElectronWindowTitleBarStyle = "hiddenInset" | "hidden" | "default" | "customButtonsOnHover"; + +export interface FileSource { + file: string; +} +export interface UrlSource { + url: string; +} +export type ContentSource = RequireExactlyOne; + +export interface ElectronWindowConfiguration { + id: string; + title: string; + defaultHeight: number; + defaultWidth: number; + getContentSource: () => ContentSource; + resizable: boolean; + windowFrameUtilitiesAreShown: boolean; + centered: boolean; + titleBarStyle?: ElectronWindowTitleBarStyle; + beforeOpen?: () => Promise; + onClose: () => void; + onFocus?: () => void; + onBlur?: () => void; + onDomReady?: () => void; +} + +export type CreateElectronWindow = () => Promise; +export type CreateElectronWindowFor = (config: ElectronWindowConfiguration) => CreateElectronWindow; + +function isFileSource(src: ContentSource): src is FileSource { + return typeof (src as FileSource).file === "string"; +} + +const createElectronWindowFor = getInjectable({ + id: "create-electron-window-for", + + instantiate: (di): CreateElectronWindowFor => { + const logger = di.inject(loggerInjectable); + const sendToChannelInLensWindow = di.inject(sendToChannelInElectronBrowserWindowInjectable); + + return (configuration) => async () => { + const applicationWindowState = di.inject( + applicationWindowStateInjectable, + { + id: configuration.id, + defaultHeight: configuration.defaultHeight, + defaultWidth: configuration.defaultWidth, + }, + ); + + const { width, height, x, y } = applicationWindowState; + + const browserWindow = new BrowserWindow({ + x, + y, + width, + height, + title: configuration.title, + resizable: configuration.resizable, + center: configuration.centered, + frame: configuration.windowFrameUtilitiesAreShown, + show: false, + minWidth: 700, // accommodate 800 x 600 display minimum + minHeight: 500, // accommodate 800 x 600 display minimum + titleBarStyle: configuration.titleBarStyle, + backgroundColor: "#1e2124", + webPreferences: { + nodeIntegration: true, + nodeIntegrationInSubFrames: true, + webviewTag: true, + contextIsolation: false, + nativeWindowOpen: false, + }, + }); + + applicationWindowState.manage(browserWindow); + + browserWindow + .on("focus", () => { + configuration.onFocus?.(); + }) + + .on("blur", () => { + configuration.onBlur?.(); + }) + + .on("closed", () => { + configuration.onClose(); + applicationWindowState.unmanage(); + }) + + .webContents.on("dom-ready", () => { + configuration.onDomReady?.(); + }) + + .on("did-fail-load", (_event, code, desc) => { + logger.error( + `[CREATE-ELECTRON-WINDOW]: Failed to load window "${configuration.id}"`, + { + code, + desc, + }, + ); + }) + + .on("did-finish-load", () => { + logger.info( + `[CREATE-ELECTRON-WINDOW]: Window "${configuration.id}" loaded`, + ); + }) + + .on("will-attach-webview", (event, webPreferences, params) => { + logger.debug( + `[CREATE-ELECTRON-WINDOW]: Attaching webview to window "${configuration.id}"`, + ); + // Following is security recommendations because we allow webview tag (webviewTag: true) + // suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation + // and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups + + if (webPreferences.preload) { + logger.warn( + "[CREATE-ELECTRON-WINDOW]: Strip away preload scripts of webview", + ); + delete webPreferences.preload; + } + + // @ts-expect-error some electron version uses webPreferences.preloadURL/webPreferences.preload + if (webPreferences.preloadURL) { + logger.warn( + "[CREATE-ELECTRON-WINDOW]: Strip away preload scripts of webview", + ); + delete webPreferences.preload; + } + + if (params.allowpopups) { + logger.warn( + "[CREATE-ELECTRON-WINDOW]: We do not allow allowpopups props, stop webview from renderer", + ); + + // event.preventDefault() will destroy the guest page. + event.preventDefault(); + + return; + } + + // Always disable Node.js integration for all webviews + webPreferences.nodeIntegration = false; + }) + + .setWindowOpenHandler((details) => { + openBrowser(details.url).catch((error) => { + logger.error("[CREATE-ELECTRON-WINDOW]: failed to open browser", { + error, + }); + }); + + return { action: "deny" }; + }); + + const contentSource = configuration.getContentSource(); + + if (isFileSource(contentSource)) { + logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from file: ${contentSource.file}...`); + await browserWindow.loadFile(contentSource.file); + } else { + logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from url: ${contentSource.url}...`); + await browserWindow.loadURL(contentSource.url); + } + + await configuration.beforeOpen?.(); + + return { + show: () => browserWindow.show(), + close: () => browserWindow.close(), + send: (args) => sendToChannelInLensWindow(browserWindow, args), + }; + }; + }, + + causesSideEffects: true, +}); + +export default createElectronWindowFor; diff --git a/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts b/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts new file mode 100644 index 0000000000..a44b0ebd4c --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { SendToViewArgs } from "./lens-window-injection-token"; +import type { ContentSource, ElectronWindowTitleBarStyle } from "./create-electron-window-for.injectable"; +import createElectronWindowForInjectable from "./create-electron-window-for.injectable"; + +export interface LensWindow { + show: () => void; + close: () => void; + send: (args: SendToViewArgs) => void; +} + +export interface LensWindowConfiguration { + id: string; + title: string; + defaultHeight: number; + defaultWidth: number; + getContentSource: () => ContentSource; + resizable: boolean; + windowFrameUtilitiesAreShown: boolean; + centered: boolean; + titleBarStyle?: ElectronWindowTitleBarStyle; + beforeOpen?: () => Promise; + onFocus?: () => void; + onBlur?: () => void; + onDomReady?: () => void; +} + +const createLensWindowInjectable = getInjectable({ + id: "create-lens-window", + + instantiate: (di) => { + const createElectronWindowFor = di.inject(createElectronWindowForInjectable); + + return (configuration: LensWindowConfiguration) => { + let browserWindow: LensWindow | undefined; + + const createElectronWindow = createElectronWindowFor({ + ...configuration, + onClose: () => browserWindow = undefined, + }); + + return { + get visible() { + return !!browserWindow; + }, + show: async () => { + if (!browserWindow) { + browserWindow = await createElectronWindow(); + } + + browserWindow.show(); + }, + close: () => { + browserWindow?.close(); + browserWindow = undefined; + }, + send: (args: SendToViewArgs) => { + if (!browserWindow) { + throw new Error(`Tried to send message to window "${configuration.id}" but the window was closed`); + } + + return browserWindow.send(args); + }, + }; + }; + }, +}); + +export default createLensWindowInjectable; diff --git a/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts b/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts new file mode 100644 index 0000000000..3e62b0894b --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { ClusterFrameInfo } from "../../../../common/cluster-frames"; + +export interface SendToViewArgs { + channel: string; + frameInfo?: ClusterFrameInfo; + data?: unknown[]; +} + +export interface LensWindow { + show: () => Promise; + close: () => void; + send: (args: SendToViewArgs) => void; + visible: boolean; +} + +export const lensWindowInjectionToken = getInjectionToken({ + id: "lens-window", +}); diff --git a/src/main/start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable.ts b/src/main/start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable.ts new file mode 100644 index 0000000000..32422b6a94 --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { BrowserWindow } from "electron"; +import type { SendToViewArgs } from "./lens-window-injection-token"; + +const sendToChannelInElectronBrowserWindowInjectable = getInjectable({ + id: "send-to-channel-in-electron-browser-window", + + instantiate: + () => + ( + browserWindow: BrowserWindow, + { channel, frameInfo, data = [] }: SendToViewArgs, + ) => { + if (frameInfo) { + browserWindow.webContents.sendToFrame( + [frameInfo.processId, frameInfo.frameId], + channel, + ...data, + ); + } else { + browserWindow.webContents.send(channel, ...data); + } + }, + + causesSideEffects: true, +}); + +export default sendToChannelInElectronBrowserWindowInjectable; diff --git a/src/main/start-main-application/lens-window/current-cluster-frame/current-cluster-frame-cluster-id-state.injectable.ts b/src/main/start-main-application/lens-window/current-cluster-frame/current-cluster-frame-cluster-id-state.injectable.ts new file mode 100644 index 0000000000..b45447db5e --- /dev/null +++ b/src/main/start-main-application/lens-window/current-cluster-frame/current-cluster-frame-cluster-id-state.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { ClusterId } from "../../../../common/cluster-types"; + +const currentClusterFrameClusterIdStateInjectable = getInjectable({ + id: "current-cluster-frame-cluster-id-state", + + instantiate: () => observable.box(), +}); + +export default currentClusterFrameClusterIdStateInjectable; diff --git a/src/main/start-main-application/lens-window/current-cluster-frame/current-cluster-frame.injectable.ts b/src/main/start-main-application/lens-window/current-cluster-frame/current-cluster-frame.injectable.ts new file mode 100644 index 0000000000..2749b23253 --- /dev/null +++ b/src/main/start-main-application/lens-window/current-cluster-frame/current-cluster-frame.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import currentClusterFrameClusterIdStateInjectable from "./current-cluster-frame-cluster-id-state.injectable"; +import clusterFramesInjectable from "../../../../common/cluster-frames.injectable"; + +const currentClusterFrameInjectable = getInjectable({ + id: "current-cluster-frame", + + instantiate: (di) => { + const currentClusterFrameState = di.inject(currentClusterFrameClusterIdStateInjectable); + const clusterFrames = di.inject(clusterFramesInjectable); + + return computed(() => { + const clusterId = currentClusterFrameState.get(); + + return clusterFrames.get(clusterId); + }); + }, +}); + +export default currentClusterFrameInjectable; diff --git a/src/main/start-main-application/lens-window/current-cluster-frame/setup-listener-for-current-cluster-frame.injectable.ts b/src/main/start-main-application/lens-window/current-cluster-frame/setup-listener-for-current-cluster-frame.injectable.ts new file mode 100644 index 0000000000..fa8b36c03a --- /dev/null +++ b/src/main/start-main-application/lens-window/current-cluster-frame/setup-listener-for-current-cluster-frame.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { ipcMainOn } from "../../../../common/ipc"; +import { IpcRendererNavigationEvents } from "../../../../renderer/navigation/events"; +import type { ClusterId } from "../../../../common/cluster-types"; + +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../runnable-tokens/on-load-of-application-injection-token"; +import currentClusterFrameClusterIdStateInjectable from "./current-cluster-frame-cluster-id-state.injectable"; + +const setupListenerForCurrentClusterFrameInjectable = getInjectable({ + id: "setup-listener-for-current-cluster-frame", + + instantiate: (di) => ({ + run: () => { + const currentClusterFrameState = di.inject(currentClusterFrameClusterIdStateInjectable); + + ipcMainOn( + IpcRendererNavigationEvents.CLUSTER_VIEW_CURRENT_ID, + (event, clusterId: ClusterId) => { + currentClusterFrameState.set(clusterId); + }, + ); + }, + }), + + causesSideEffects: true, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupListenerForCurrentClusterFrameInjectable; diff --git a/src/main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable.ts b/src/main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable.ts new file mode 100644 index 0000000000..f9bbd34935 --- /dev/null +++ b/src/main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { lensWindowInjectionToken } from "../application-window/lens-window-injection-token"; + +const closeAllWindowsInjectable = getInjectable({ + id: "close-all-windows", + + instantiate: (di) => () => { + const lensWindows = di.injectMany(lensWindowInjectionToken); + + lensWindows.forEach((lensWindow) => { + lensWindow.close(); + }); + }, +}); + +export default closeAllWindowsInjectable; diff --git a/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts b/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts new file mode 100644 index 0000000000..7bada3f3bd --- /dev/null +++ b/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { iter } from "../../../common/utils"; +import clusterFramesInjectable from "../../../common/cluster-frames.injectable"; +import showApplicationWindowInjectable from "./show-application-window.injectable"; +import applicationWindowInjectable from "./application-window/application-window.injectable"; + +export type NavigateForExtension = ( + extId: string, + pageId?: string, + params?: Record, + frameId?: number +) => Promise; + +const navigateForExtensionInjectable = getInjectable({ + id: "navigate-for-extension", + + instantiate: (di): NavigateForExtension => { + const applicationWindow = di.inject(applicationWindowInjectable); + const clusterFrames = di.inject(clusterFramesInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + + return async ( + extId: string, + pageId?: string, + params?: Record, + frameId?: number, + ) => { + await showApplicationWindow(); + + const frameInfo = iter.find( + clusterFrames.values(), + (frameInfo) => frameInfo.frameId === frameId, + ); + + applicationWindow.send({ + channel: "extension:navigate", + frameInfo, + data: [extId, pageId, params], + }); + }; + }, +}); + +export default navigateForExtensionInjectable; diff --git a/src/main/start-main-application/lens-window/navigate.injectable.ts b/src/main/start-main-application/lens-window/navigate.injectable.ts new file mode 100644 index 0000000000..f9d80e4205 --- /dev/null +++ b/src/main/start-main-application/lens-window/navigate.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { iter } from "../../../common/utils"; +import applicationWindowInjectable from "./application-window/application-window.injectable"; +import clusterFramesInjectable from "../../../common/cluster-frames.injectable"; +import { IpcRendererNavigationEvents } from "../../../renderer/navigation/events"; +import showApplicationWindowInjectable from "./show-application-window.injectable"; + +const navigateInjectable = getInjectable({ + id: "navigate", + + instantiate: (di) => { + const applicationWindow = di.inject(applicationWindowInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const clusterFrames = di.inject(clusterFramesInjectable); + + return async (url: string, frameId?: number) => { + await showApplicationWindow(); + + const frameInfo = iter.find( + clusterFrames.values(), + (frameInfo) => frameInfo.frameId === frameId, + ); + + const channel = frameInfo + ? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER + : IpcRendererNavigationEvents.NAVIGATE_IN_APP; + + applicationWindow.send({ + channel, + frameInfo, + data: [url], + }); + }; + }, +}); + +export default navigateInjectable; diff --git a/src/main/start-main-application/lens-window/reload-all-windows.injectable.ts b/src/main/start-main-application/lens-window/reload-all-windows.injectable.ts new file mode 100644 index 0000000000..fca4da5e13 --- /dev/null +++ b/src/main/start-main-application/lens-window/reload-all-windows.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { webContents } from "electron"; + +const reloadAllWindowsInjectable = getInjectable({ + id: "reload-all-windows", + + instantiate: () => () => { + webContents + .getAllWebContents() + .filter((wc) => wc.getType() === "window") + .forEach((wc) => { + wc.reload(); + wc.clearHistory(); + }); + }, + + causesSideEffects: true, +}); + +export default reloadAllWindowsInjectable; diff --git a/src/main/start-main-application/lens-window/reload-window.injectable.ts b/src/main/start-main-application/lens-window/reload-window.injectable.ts new file mode 100644 index 0000000000..99d3f22f14 --- /dev/null +++ b/src/main/start-main-application/lens-window/reload-window.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { LensWindow } from "./application-window/lens-window-injection-token"; +import { IpcRendererNavigationEvents } from "../../../renderer/navigation/events"; +import currentClusterFrameInjectable from "./current-cluster-frame/current-cluster-frame.injectable"; +import reloadAllWindowsInjectable from "./reload-all-windows.injectable"; + +const reloadWindowInjectable = getInjectable({ + id: "reload-window", + + instantiate: (di, lensWindow: LensWindow) => () => { + const currentClusterIframe = di.inject(currentClusterFrameInjectable); + const reloadAllWindows = di.inject(reloadAllWindowsInjectable); + + const frameInfo = currentClusterIframe.get(); + + if (frameInfo) { + lensWindow.send({ + channel: IpcRendererNavigationEvents.RELOAD_PAGE, + frameInfo, + }); + } else { + reloadAllWindows(); + } + }, + + lifecycle: lifecycleEnum.transient, +}); + +export default reloadWindowInjectable; diff --git a/src/main/start-main-application/lens-window/show-application-window.injectable.ts b/src/main/start-main-application/lens-window/show-application-window.injectable.ts new file mode 100644 index 0000000000..b514c41891 --- /dev/null +++ b/src/main/start-main-application/lens-window/show-application-window.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import splashWindowInjectable from "./splash-window/splash-window.injectable"; +import applicationWindowInjectable from "./application-window/application-window.injectable"; + +const showApplicationWindowInjectable = getInjectable({ + id: "show-application-window", + + instantiate: (di) => { + const applicationWindow = di.inject(applicationWindowInjectable); + + const splashWindow = di.inject( + splashWindowInjectable, + ); + + return async () => { + if (applicationWindow.visible) { + return; + } + + await splashWindow.show(); + + await applicationWindow.show(); + + splashWindow.close(); + }; + }, +}); + +export default showApplicationWindowInjectable; diff --git a/src/main/start-main-application/lens-window/splash-window/splash-window.injectable.ts b/src/main/start-main-application/lens-window/splash-window/splash-window.injectable.ts new file mode 100644 index 0000000000..ab8c2c0c35 --- /dev/null +++ b/src/main/start-main-application/lens-window/splash-window/splash-window.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { lensWindowInjectionToken } from "../application-window/lens-window-injection-token"; +import createLensWindowInjectable from "../application-window/create-lens-window.injectable"; +import staticFilesDirectoryInjectable from "../../../../common/vars/static-files-directory.injectable"; +import getAbsolutePathInjectable from "../../../../common/path/get-absolute-path.injectable"; + +const splashWindowInjectable = getInjectable({ + id: "splash-window", + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const splashWindowFile = getAbsolutePath(staticFilesDirectory, "splash.html"); + + return createLensWindow({ + id: "splash", + title: "Loading", + getContentSource: () => ({ + file: splashWindowFile, + }), + defaultWidth: 500, + defaultHeight: 300, + resizable: false, + windowFrameUtilitiesAreShown: false, + centered: true, + }); + }, + + injectionToken: lensWindowInjectionToken, +}); + +export default splashWindowInjectable; diff --git a/src/main/start-main-application/runnable-tokens/after-application-is-loaded-injection-token.ts b/src/main/start-main-application/runnable-tokens/after-application-is-loaded-injection-token.ts new file mode 100644 index 0000000000..f89f445ecf --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/after-application-is-loaded-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "../../../common/runnable/run-many-for"; + +export const afterApplicationIsLoadedInjectionToken = getInjectionToken({ + id: "after-application-is-loaded", +}); diff --git a/src/main/start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token.ts b/src/main/start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token.ts new file mode 100644 index 0000000000..f066c124ba --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "../../../common/runnable/run-many-for"; + +export const afterRootFrameIsReadyInjectionToken = getInjectionToken({ + id: "after-root-frame-is-ready", +}); diff --git a/src/main/start-main-application/runnable-tokens/after-window-is-opened-injection-token.ts b/src/main/start-main-application/runnable-tokens/after-window-is-opened-injection-token.ts new file mode 100644 index 0000000000..d5f33bceff --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/after-window-is-opened-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "../../../common/runnable/run-many-for"; + +export const afterWindowIsOpenedInjectionToken = getInjectionToken({ + id: "after-window-is-opened", +}); diff --git a/src/main/start-main-application/runnable-tokens/before-application-is-loading-injection-token.ts b/src/main/start-main-application/runnable-tokens/before-application-is-loading-injection-token.ts new file mode 100644 index 0000000000..7cda9e6aee --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/before-application-is-loading-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "../../../common/runnable/run-many-for"; + +export const beforeApplicationIsLoadingInjectionToken = getInjectionToken({ + id: "before-application-is-loading", +}); diff --git a/src/main/start-main-application/runnable-tokens/before-electron-is-ready-injection-token.ts b/src/main/start-main-application/runnable-tokens/before-electron-is-ready-injection-token.ts new file mode 100644 index 0000000000..08173ebef2 --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/before-electron-is-ready-injection-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RunnableSync } from "../../../common/runnable/run-many-sync-for"; + +export const beforeElectronIsReadyInjectionToken = + getInjectionToken({ + id: "before-electron-is-ready", + }); diff --git a/src/main/start-main-application/runnable-tokens/before-quit-of-back-end-injection-token.ts b/src/main/start-main-application/runnable-tokens/before-quit-of-back-end-injection-token.ts new file mode 100644 index 0000000000..bdb1d1e1be --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/before-quit-of-back-end-injection-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RunnableSync } from "../../../common/runnable/run-many-sync-for"; + +export const beforeQuitOfBackEndInjectionToken = + getInjectionToken({ + id: "before-quit-of-back-end", + }); diff --git a/src/main/start-main-application/runnable-tokens/before-quit-of-front-end-injection-token.ts b/src/main/start-main-application/runnable-tokens/before-quit-of-front-end-injection-token.ts new file mode 100644 index 0000000000..2327688cb3 --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/before-quit-of-front-end-injection-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RunnableSync } from "../../../common/runnable/run-many-sync-for"; + +export const beforeQuitOfFrontEndInjectionToken = + getInjectionToken({ + id: "before-quit-of-front-end", + }); diff --git a/src/main/start-main-application/runnable-tokens/on-load-of-application-injection-token.ts b/src/main/start-main-application/runnable-tokens/on-load-of-application-injection-token.ts new file mode 100644 index 0000000000..35b7a6c0ff --- /dev/null +++ b/src/main/start-main-application/runnable-tokens/on-load-of-application-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "../../../common/runnable/run-many-for"; + +export const onLoadOfApplicationInjectionToken = getInjectionToken({ + id: "on-load-of-application", +}); diff --git a/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts b/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts new file mode 100644 index 0000000000..07066535a1 --- /dev/null +++ b/src/main/start-main-application/runnables/clean-up-shell-sessions.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../runnable-tokens/before-quit-of-back-end-injection-token"; +import { ShellSession } from "../../shell-session/shell-session"; + +const cleanUpShellSessionsInjectable = getInjectable({ + id: "clean-up-shell-sessions", + + instantiate: () => ({ + run: () => { + ShellSession.cleanup(); + }, + }), + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default cleanUpShellSessionsInjectable; diff --git a/src/main/start-main-application/runnables/emit-close-to-event-bus.injectable.ts b/src/main/start-main-application/runnables/emit-close-to-event-bus.injectable.ts new file mode 100644 index 0000000000..316114f205 --- /dev/null +++ b/src/main/start-main-application/runnables/emit-close-to-event-bus.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../runnable-tokens/before-quit-of-front-end-injection-token"; + +const emitCloseToEventBusInjectable = getInjectable({ + id: "emit-close-to-event-bus", + + instantiate: (di) => { + const appEventBus = di.inject(appEventBusInjectable); + + return { + run: () => { + appEventBus.emit({ name: "app", action: "close" }); + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default emitCloseToEventBusInjectable; diff --git a/src/main/start-main-application/runnables/emit-service-start-to-event-bus.injectable.ts b/src/main/start-main-application/runnables/emit-service-start-to-event-bus.injectable.ts new file mode 100644 index 0000000000..0d3e4cf043 --- /dev/null +++ b/src/main/start-main-application/runnables/emit-service-start-to-event-bus.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; +import { afterApplicationIsLoadedInjectionToken } from "../runnable-tokens/after-application-is-loaded-injection-token"; + +const emitServiceStartToEventBusInjectable = getInjectable({ + id: "emit-service-start-to-event-bus", + + instantiate: (di) => { + const appEventBus = di.inject(appEventBusInjectable); + + return { + run: () => { + appEventBus.emit({ name: "service", action: "start" }); + }, + }; + }, + + injectionToken: afterApplicationIsLoadedInjectionToken, +}); + +export default emitServiceStartToEventBusInjectable; diff --git a/src/main/start-main-application/runnables/flag-renderer/flag-renderer-as-loaded.injectable.ts b/src/main/start-main-application/runnables/flag-renderer/flag-renderer-as-loaded.injectable.ts new file mode 100644 index 0000000000..3c61b2a011 --- /dev/null +++ b/src/main/start-main-application/runnables/flag-renderer/flag-renderer-as-loaded.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { afterRootFrameIsReadyInjectionToken } from "../../runnable-tokens/after-root-frame-is-ready-injection-token"; +import lensProtocolRouterMainInjectable from "../../../protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; +import { runInAction } from "mobx"; + +const flagRendererAsLoadedInjectable = getInjectable({ + id: "flag-renderer-as-loaded", + + instantiate: (di) => { + const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); + + return { + run: () => { + runInAction(() => { + // Todo: remove this kludge which enables out-of-place temporal dependency. + lensProtocolRouterMain.rendererLoaded = true; + }); + }, + }; + }, + + injectionToken: afterRootFrameIsReadyInjectionToken, +}); + +export default flagRendererAsLoadedInjectable; diff --git a/src/main/start-main-application/runnables/flag-renderer/flag-renderer-as-not-loaded.injectable.ts b/src/main/start-main-application/runnables/flag-renderer/flag-renderer-as-not-loaded.injectable.ts new file mode 100644 index 0000000000..d81f7287aa --- /dev/null +++ b/src/main/start-main-application/runnables/flag-renderer/flag-renderer-as-not-loaded.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import lensProtocolRouterMainInjectable from "../../../protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../../runnable-tokens/before-quit-of-front-end-injection-token"; + +const flagRendererAsNotLoadedInjectable = getInjectable({ + id: "stop-deep-linking", + + instantiate: (di) => { + const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); + + return { + run: () => { + runInAction(() => { + // Todo: remove this kludge which enables out-of-place temporal dependency. + lensProtocolRouterMain.rendererLoaded = false; + }); + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default flagRendererAsNotLoadedInjectable; diff --git a/src/main/start-main-application/runnables/initialize-extensions.injectable.ts b/src/main/start-main-application/runnables/initialize-extensions.injectable.ts new file mode 100644 index 0000000000..899d684abf --- /dev/null +++ b/src/main/start-main-application/runnables/initialize-extensions.injectable.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { InstalledExtension } from "../../../extensions/extension-discovery/extension-discovery"; +import type { LensExtensionId } from "../../../extensions/lens-extension"; +import loggerInjectable from "../../../common/logger.injectable"; +import extensionDiscoveryInjectable from "../../../extensions/extension-discovery/extension-discovery.injectable"; +import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; +import showErrorPopupInjectable from "../../electron-app/features/show-error-popup.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const initializeExtensionsInjectable = getInjectable({ + id: "initialize-extensions", + + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + const extensionDiscovery = di.inject(extensionDiscoveryInjectable); + const extensionLoader = di.inject(extensionLoaderInjectable); + const showErrorPopup = di.inject(showErrorPopupInjectable); + + return { + run: async () => { + logger.info("🧩 Initializing extensions"); + + await extensionDiscovery.init(); + + await extensionLoader.init(); + + try { + const extensions = await extensionDiscovery.load(); + + // Start watching after bundled extensions are loaded + extensionDiscovery.watchExtensions(); + + // Subscribe to extensions that are copied or deleted to/from the extensions folder + extensionDiscovery.events + .on("add", (extension: InstalledExtension) => { + extensionLoader.addExtension(extension); + }) + .on("remove", (lensExtensionId: LensExtensionId) => { + extensionLoader.removeExtension(lensExtensionId); + }); + + extensionLoader.initExtensions(extensions); + } catch (error: any) { + showErrorPopup( + "Lens Error", + `Could not load extensions${ + error?.message ? `: ${error.message}` : "" + }`, + ); + + console.error(error); + console.trace(); + } + }, + }; + }, + + causesSideEffects: true, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default initializeExtensionsInjectable; diff --git a/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts b/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts new file mode 100644 index 0000000000..80c725e17d --- /dev/null +++ b/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { afterRootFrameIsReadyInjectionToken } from "../../runnable-tokens/after-root-frame-is-ready-injection-token"; +import directoryForKubeConfigsInjectable from "../../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import ensureDirInjectable from "../../../../common/fs/ensure-dir.injectable"; +import kubeconfigSyncManagerInjectable from "../../../catalog-sources/kubeconfig-sync/manager.injectable"; + +const startKubeConfigSyncInjectable = getInjectable({ + id: "start-kubeconfig-sync", + + instantiate: (di) => { + const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); + const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); + const ensureDir = di.inject(ensureDirInjectable); + + return { + run: async () => { + await ensureDir(directoryForKubeConfigs); + + kubeConfigSyncManager.startSync(); + }, + }; + }, + + causesSideEffects: true, + + injectionToken: afterRootFrameIsReadyInjectionToken, +}); + +export default startKubeConfigSyncInjectable; diff --git a/src/main/start-main-application/runnables/kube-config-sync/stop-kube-config-sync.injectable.ts b/src/main/start-main-application/runnables/kube-config-sync/stop-kube-config-sync.injectable.ts new file mode 100644 index 0000000000..2613ab77ba --- /dev/null +++ b/src/main/start-main-application/runnables/kube-config-sync/stop-kube-config-sync.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../../runnable-tokens/before-quit-of-front-end-injection-token"; +import kubeconfigSyncManagerInjectable from "../../../catalog-sources/kubeconfig-sync/manager.injectable"; + +const stopKubeConfigSyncInjectable = getInjectable({ + id: "stop-kube-config-sync", + + instantiate: (di) => { + const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); + + return { + run: () => { + kubeConfigSyncManager.stopSync(); + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default stopKubeConfigSyncInjectable; diff --git a/src/main/start-main-application/runnables/root-frame-rendered-channel-listener/root-frame-rendered-channel-listener.injectable.ts b/src/main/start-main-application/runnables/root-frame-rendered-channel-listener/root-frame-rendered-channel-listener.injectable.ts new file mode 100644 index 0000000000..83ebf7bf91 --- /dev/null +++ b/src/main/start-main-application/runnables/root-frame-rendered-channel-listener/root-frame-rendered-channel-listener.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import rootFrameRenderedChannelInjectable from "../../../../common/root-frame-rendered-channel/root-frame-rendered-channel.injectable"; +import { runManyFor } from "../../../../common/runnable/run-many-for"; +import { afterRootFrameIsReadyInjectionToken } from "../../runnable-tokens/after-root-frame-is-ready-injection-token"; +import { messageChannelListenerInjectionToken } from "../../../../common/utils/channel/message-channel-listener-injection-token"; + +const rootFrameRenderedChannelListenerInjectable = getInjectable({ + id: "root-frame-rendered-channel-listener", + + instantiate: (di) => { + const channel = di.inject(rootFrameRenderedChannelInjectable); + + const runMany = runManyFor(di); + + const runRunnablesAfterRootFrameIsReady = runMany( + afterRootFrameIsReadyInjectionToken, + ); + + return { + channel, + + handler: async () => { + await runRunnablesAfterRootFrameIsReady(); + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default rootFrameRenderedChannelListenerInjectable; diff --git a/src/main/start-main-application/runnables/setup-detector-registry.injectable.ts b/src/main/start-main-application/runnables/setup-detector-registry.injectable.ts new file mode 100644 index 0000000000..1c91e797f3 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-detector-registry.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { ClusterIdDetector } from "../../cluster-detectors/cluster-id-detector"; +import { LastSeenDetector } from "../../cluster-detectors/last-seen-detector"; +import { VersionDetector } from "../../cluster-detectors/version-detector"; +import { DistributionDetector } from "../../cluster-detectors/distribution-detector"; +import { NodesCountDetector } from "../../cluster-detectors/nodes-count-detector"; +import detectorRegistryInjectable from "../../cluster-detectors/detector-registry.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const setupDetectorRegistryInjectable = getInjectable({ + id: "setup-detector-registry", + + instantiate: (di) => { + const detectorRegistry = di.inject(detectorRegistryInjectable); + + return { + run: () => { + detectorRegistry + .add(ClusterIdDetector) + .add(LastSeenDetector) + .add(VersionDetector) + .add(DistributionDetector) + .add(NodesCountDetector); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupDetectorRegistryInjectable; diff --git a/src/main/start-main-application/runnables/setup-hardware-acceleration.injectable.ts b/src/main/start-main-application/runnables/setup-hardware-acceleration.injectable.ts new file mode 100644 index 0000000000..ba1b5f2bb8 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-hardware-acceleration.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import environmentVariablesInjectable from "../../../common/utils/environment-variables.injectable"; +import disableHardwareAccelerationInjectable from "../../electron-app/features/disable-hardware-acceleration.injectable"; +import { beforeElectronIsReadyInjectionToken } from "../runnable-tokens/before-electron-is-ready-injection-token"; + +const setupHardwareAccelerationInjectable = getInjectable({ + id: "setup-hardware-acceleration", + + instantiate: (di) => { + const { LENS_DISABLE_GPU: hardwareAccelerationShouldBeDisabled } = di.inject(environmentVariablesInjectable); + const disableHardwareAcceleration = di.inject(disableHardwareAccelerationInjectable); + + return { + run: () => { + if (hardwareAccelerationShouldBeDisabled) { + disableHardwareAcceleration(); + } + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupHardwareAccelerationInjectable; diff --git a/src/main/start-main-application/runnables/setup-hotbar-store.injectable.ts b/src/main/start-main-application/runnables/setup-hotbar-store.injectable.ts new file mode 100644 index 0000000000..372c339e5e --- /dev/null +++ b/src/main/start-main-application/runnables/setup-hotbar-store.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import setupSyncingOfGeneralCatalogEntitiesInjectable from "./setup-syncing-of-general-catalog-entities.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; +import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; + +const setupHotbarStoreInjectable = getInjectable({ + id: "setup-hotbar-store", + + instantiate: (di) => ({ + run: () => { + const hotbarStore = di.inject(hotbarStoreInjectable); + + hotbarStore.load(); + }, + + runAfter: di.inject(setupSyncingOfGeneralCatalogEntitiesInjectable), + }), + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupHotbarStoreInjectable; diff --git a/src/main/start-main-application/runnables/setup-immer.injectable.ts b/src/main/start-main-application/runnables/setup-immer.injectable.ts new file mode 100644 index 0000000000..63cd6e3f2f --- /dev/null +++ b/src/main/start-main-application/runnables/setup-immer.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import * as Immer from "immer"; +import { beforeElectronIsReadyInjectionToken } from "../runnable-tokens/before-electron-is-ready-injection-token"; + +const setupImmerInjectable = getInjectable({ + id: "setup-immer", + + instantiate: () => ({ + run: () => { + // Docs: https://immerjs.github.io/immer/ + // Required in `utils/storage-helper.ts` + Immer.setAutoFreeze(false); // allow to merge mobx observables + Immer.enableMapSet(); // allow to merge maps and sets + }, + }), + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupImmerInjectable; diff --git a/src/main/start-main-application/runnables/setup-lens-proxy.injectable.ts b/src/main/start-main-application/runnables/setup-lens-proxy.injectable.ts new file mode 100644 index 0000000000..8b90bcf5e3 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-lens-proxy.injectable.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getAppVersion, getAppVersionFromProxyServer } from "../../../common/utils"; +import exitAppInjectable from "../../electron-app/features/exit-app.injectable"; +import lensProxyInjectable from "../../lens-proxy/lens-proxy.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import lensProxyPortInjectable from "../../lens-proxy/lens-proxy-port.injectable"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import showErrorPopupInjectable from "../../electron-app/features/show-error-popup.injectable"; +import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token"; + +const setupLensProxyInjectable = getInjectable({ + id: "setup-lens-proxy", + + instantiate: (di) => { + const lensProxy = di.inject(lensProxyInjectable); + const exitApp = di.inject(exitAppInjectable); + const logger = di.inject(loggerInjectable); + const lensProxyPort = di.inject(lensProxyPortInjectable); + const isWindows = di.inject(isWindowsInjectable); + const showErrorPopup = di.inject(showErrorPopupInjectable); + + return { + run: async () => { + try { + logger.info("🔌 Starting LensProxy"); + await lensProxy.listen(); // lensProxy.port available + } catch (error: any) { + showErrorPopup("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); + + return exitApp(); + } + + // test proxy connection + try { + logger.info("🔎 Testing LensProxy connection ..."); + const versionFromProxy = await getAppVersionFromProxyServer( + lensProxyPort.get(), + ); + + if (getAppVersion() !== versionFromProxy) { + logger.error("Proxy server responded with invalid response"); + + return exitApp(); + } + + logger.info("⚡ LensProxy connection OK"); + } catch (error) { + logger.error(`🛑 LensProxy: failed connection test: ${error}`); + + const hostsPath = isWindows + ? "C:\\windows\\system32\\drivers\\etc\\hosts" + : "/etc/hosts"; + const message = [ + `Failed connection test: ${error}`, + "Check to make sure that no other versions of Lens are running", + `Check ${hostsPath} to make sure that it is clean and that the localhost loopback is at the top and set to 127.0.0.1`, + "If you have HTTP_PROXY or http_proxy set in your environment, make sure that the localhost and the ipv4 loopback address 127.0.0.1 are added to the NO_PROXY environment variable.", + ]; + + showErrorPopup("Lens Proxy Error", message.join("\n\n")); + + return exitApp(); + } + }, + }; + }, + + causesSideEffects: true, + + injectionToken: beforeApplicationIsLoadingInjectionToken, +}); + +export default setupLensProxyInjectable; diff --git a/src/main/start-main-application/runnables/setup-mobx.injectable.ts b/src/main/start-main-application/runnables/setup-mobx.injectable.ts new file mode 100644 index 0000000000..ca5a124b77 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-mobx.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import * as Mobx from "mobx"; +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeElectronIsReadyInjectionToken } from "../runnable-tokens/before-electron-is-ready-injection-token"; + +const setupMobxInjectable = getInjectable({ + id: "setup-mobx", + + instantiate: () => ({ + run: () => { + // Docs: https://mobx.js.org/configuration.html + Mobx.configure({ + enforceActions: "never", + + // TODO: enable later (read more: https://mobx.js.org/migrating-from-4-or-5.html) + // computedRequiresReaction: true, + // reactionRequiresObservable: true, + // observableRequiresReaction: true, + }); + }, + }), + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupMobxInjectable; diff --git a/src/main/start-main-application/runnables/setup-prometheus-registry.injectable.ts b/src/main/start-main-application/runnables/setup-prometheus-registry.injectable.ts new file mode 100644 index 0000000000..c3756bb1b1 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-prometheus-registry.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { PrometheusLens } from "../../prometheus/lens"; +import { PrometheusHelm } from "../../prometheus/helm"; +import { PrometheusOperator } from "../../prometheus/operator"; +import { PrometheusStacklight } from "../../prometheus/stacklight"; +import prometheusProviderRegistryInjectable from "../../prometheus/prometheus-provider-registry.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const setupPrometheusRegistryInjectable = getInjectable({ + id: "setup-prometheus-registry", + + instantiate: (di) => { + const prometheusProviderRegistry = di.inject(prometheusProviderRegistryInjectable); + + return { + run: () => { + prometheusProviderRegistry + .registerProvider(new PrometheusLens()) + .registerProvider(new PrometheusHelm()) + .registerProvider(new PrometheusOperator()) + .registerProvider(new PrometheusStacklight()); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupPrometheusRegistryInjectable; diff --git a/src/main/start-main-application/runnables/setup-proxy-env.injectable.ts b/src/main/start-main-application/runnables/setup-proxy-env.injectable.ts new file mode 100644 index 0000000000..7c05ad8b49 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-proxy-env.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeElectronIsReadyInjectionToken } from "../runnable-tokens/before-electron-is-ready-injection-token"; +import getCommandLineSwitchInjectable from "../../electron-app/features/get-command-line-switch.injectable"; + +const setupProxyEnvInjectable = getInjectable({ + id: "setup-proxy-env", + + instantiate: (di) => { + const getCommandLineSwitch = di.inject(getCommandLineSwitchInjectable); + + return { + run: () => { + const switchValue = getCommandLineSwitch("proxy-server"); + + let httpsProxy = + process.env.HTTPS_PROXY || process.env.HTTP_PROXY || ""; + + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + + if (switchValue !== "") { + httpsProxy = switchValue; + } + + if (httpsProxy !== "") { + process.env.APP_HTTPS_PROXY = httpsProxy; + } + + if (getCommandLineSwitch("proxy-server") !== "") { + process.env.HTTPS_PROXY = getCommandLineSwitch("proxy-server"); + } + }, + }; + }, + + injectionToken: beforeElectronIsReadyInjectionToken, +}); + +export default setupProxyEnvInjectable; diff --git a/src/main/start-main-application/runnables/setup-reactions-in-user-store.injectable.ts b/src/main/start-main-application/runnables/setup-reactions-in-user-store.injectable.ts new file mode 100644 index 0000000000..7ab26a1506 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-reactions-in-user-store.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import userStoreInjectable from "../../../common/user-store/user-store.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const setupReactionsInUserStoreInjectable = getInjectable({ + id: "setup-reactions-in-user-store", + + instantiate: (di) => { + const userStore = di.inject(userStoreInjectable); + + return { + run: () => { + userStore.startMainReactions(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupReactionsInUserStoreInjectable; diff --git a/src/main/start-main-application/runnables/setup-runnables-for-after-root-frame-is-ready.injectable.ts b/src/main/start-main-application/runnables/setup-runnables-for-after-root-frame-is-ready.injectable.ts new file mode 100644 index 0000000000..82c323c501 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-runnables-for-after-root-frame-is-ready.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { ipcMainOn } from "../../../common/ipc"; +import { IpcRendererNavigationEvents } from "../../../renderer/navigation/events"; +import { afterRootFrameIsReadyInjectionToken } from "../runnable-tokens/after-root-frame-is-ready-injection-token"; +import { runManyFor } from "../../../common/runnable/run-many-for"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const setupRunnablesForAfterRootFrameIsReadyInjectable = getInjectable({ + id: "setup-runnables-for-after-root-frame-is-ready", + + instantiate: (di) => { + const runMany = runManyFor(di); + + const runRunnablesAfterRootFrameIsReady = runMany( + afterRootFrameIsReadyInjectionToken, + ); + + return { + run: () => { + ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { + await runRunnablesAfterRootFrameIsReady(); + }); + }, + }; + }, + + // Direct usage of IPC + causesSideEffects: true, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupRunnablesForAfterRootFrameIsReadyInjectable; diff --git a/src/main/start-main-application/runnables/setup-sentry.injectable.ts b/src/main/start-main-application/runnables/setup-sentry.injectable.ts new file mode 100644 index 0000000000..d100b93cc6 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-sentry.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { initializeSentryReporting } from "../../../common/sentry"; +import { init } from "@sentry/electron/main"; +import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token"; + +const setupSentryInjectable = getInjectable({ + id: "setup-sentry", + + instantiate: () => ({ + run: () => { + initializeSentryReporting(init); + }, + }), + + causesSideEffects: true, + + injectionToken: beforeApplicationIsLoadingInjectionToken, +}); + +export default setupSentryInjectable; diff --git a/src/main/start-main-application/runnables/setup-shell.injectable.ts b/src/main/start-main-application/runnables/setup-shell.injectable.ts new file mode 100644 index 0000000000..0f23e0f2e8 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-shell.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shellSync } from "../../shell-sync"; +import loggerInjectable from "../../../common/logger.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const setupShellInjectable = getInjectable({ + id: "setup-shell", + + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + return { + run: async () => { + logger.info("🐚 Syncing shell environment"); + + await shellSync(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, + causesSideEffects: true, +}); + +export default setupShellInjectable; diff --git a/src/main/start-main-application/runnables/setup-syncing-of-general-catalog-entities.injectable.ts b/src/main/start-main-application/runnables/setup-syncing-of-general-catalog-entities.injectable.ts new file mode 100644 index 0000000000..e44752b324 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-syncing-of-general-catalog-entities.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import syncGeneralCatalogEntitiesInjectable from "../../catalog-sources/sync-general-catalog-entities.injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; + +const setupSyncingOfGeneralCatalogEntitiesInjectable = getInjectable({ + id: "setup-syncing-of-general-catalog-entities", + + instantiate: (di) => { + const syncGeneralCatalogEntities = di.inject( + syncGeneralCatalogEntitiesInjectable, + ); + + return { + run: () => { + syncGeneralCatalogEntities(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupSyncingOfGeneralCatalogEntitiesInjectable; diff --git a/src/main/start-main-application/runnables/setup-syncing-of-weblinks.injectable.ts b/src/main/start-main-application/runnables/setup-syncing-of-weblinks.injectable.ts new file mode 100644 index 0000000000..0e5ada1b78 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-syncing-of-weblinks.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; +import syncWeblinksInjectable from "../../catalog-sources/sync-weblinks.injectable"; + +const setupSyncingOfWeblinksInjectable = getInjectable({ + id: "setup-syncing-of-weblinks", + + instantiate: (di) => { + const syncWeblinks = di.inject(syncWeblinksInjectable); + + return { + run: () => { + syncWeblinks(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default setupSyncingOfWeblinksInjectable; diff --git a/src/main/start-main-application/runnables/setup-system-ca.injectable.ts b/src/main/start-main-application/runnables/setup-system-ca.injectable.ts new file mode 100644 index 0000000000..0a09ebebd4 --- /dev/null +++ b/src/main/start-main-application/runnables/setup-system-ca.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { injectSystemCAs } from "../../../common/system-ca"; +import { getInjectable } from "@ogre-tools/injectable"; +import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token"; + +const setupSystemCaInjectable = getInjectable({ + id: "setup-system-ca", + + instantiate: () => ({ + run: async () => { + await injectSystemCAs(); + }, + }), + + causesSideEffects: true, + + injectionToken: beforeApplicationIsLoadingInjectionToken, +}); + +export default setupSystemCaInjectable; diff --git a/src/main/start-main-application/runnables/stop-cluster-manager.injectable.ts b/src/main/start-main-application/runnables/stop-cluster-manager.injectable.ts new file mode 100644 index 0000000000..d062270dd0 --- /dev/null +++ b/src/main/start-main-application/runnables/stop-cluster-manager.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import clusterManagerInjectable from "../../cluster-manager.injectable"; +import { beforeQuitOfFrontEndInjectionToken } from "../runnable-tokens/before-quit-of-front-end-injection-token"; + +const stopClusterManagerInjectable = getInjectable({ + id: "stop-cluster-manager", + + instantiate: (di) => { + const clusterManager = di.inject(clusterManagerInjectable); + + return { + run: () => { + clusterManager.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, + + causesSideEffects: true, +}); + +export default stopClusterManagerInjectable; diff --git a/src/main/start-main-application/start-main-application.injectable.ts b/src/main/start-main-application/start-main-application.injectable.ts new file mode 100644 index 0000000000..c818d0308b --- /dev/null +++ b/src/main/start-main-application/start-main-application.injectable.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +import { runManyFor } from "../../common/runnable/run-many-for"; +import { runManySyncFor } from "../../common/runnable/run-many-sync-for"; +import { beforeElectronIsReadyInjectionToken } from "./runnable-tokens/before-electron-is-ready-injection-token"; +import { beforeApplicationIsLoadingInjectionToken } from "./runnable-tokens/before-application-is-loading-injection-token"; +import { onLoadOfApplicationInjectionToken } from "./runnable-tokens/on-load-of-application-injection-token"; +import { afterApplicationIsLoadedInjectionToken } from "./runnable-tokens/after-application-is-loaded-injection-token"; +import splashWindowInjectable from "./lens-window/splash-window/splash-window.injectable"; + +import applicationWindowInjectable from "./lens-window/application-window/application-window.injectable"; +import shouldStartHiddenInjectable from "../electron-app/features/should-start-hidden.injectable"; +import openDeepLinkInjectable from "../protocol-handler/lens-protocol-router-main/open-deep-link-for-url/open-deep-link.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import { find, map, startsWith, toLower } from "lodash/fp"; +import commandLineArgumentsInjectable from "../utils/command-line-arguments.injectable"; +import waitForElectronToBeReadyInjectable from "../electron-app/features/wait-for-electron-to-be-ready.injectable"; + +const startMainApplicationInjectable = getInjectable({ + id: "start-main-application", + + instantiate: (di) => { + const runMany = runManyFor(di); + const runManySync = runManySyncFor(di); + const waitForElectronToBeReady = di.inject(waitForElectronToBeReadyInjectable); + const applicationWindow = di.inject(applicationWindowInjectable); + const splashWindow = di.inject(splashWindowInjectable); + const shouldStartHidden = di.inject(shouldStartHiddenInjectable); + const openDeepLink = di.inject(openDeepLinkInjectable); + const commandLineArguments = di.inject(commandLineArgumentsInjectable); + + const beforeElectronIsReady = runManySync(beforeElectronIsReadyInjectionToken); + const beforeApplicationIsLoading = runMany(beforeApplicationIsLoadingInjectionToken); + const onLoadOfApplication = runMany(onLoadOfApplicationInjectionToken); + const afterApplicationIsLoaded = runMany(afterApplicationIsLoadedInjectionToken); + + return async () => { + // Stuff happening before application is ready needs to be synchronous because of + // https://github.com/electron/electron/issues/21370 + beforeElectronIsReady(); + + await waitForElectronToBeReady(); + + await beforeApplicationIsLoading(); + + if (!shouldStartHidden) { + await splashWindow.show(); + } + + await onLoadOfApplication(); + + if (!shouldStartHidden) { + const deepLinkUrl = getDeepLinkUrl(commandLineArguments); + + if (deepLinkUrl) { + await openDeepLink(deepLinkUrl); + } else { + await applicationWindow.show(); + } + + splashWindow.close(); + } + + await afterApplicationIsLoaded(); + }; + }, +}); + +const getDeepLinkUrl = (commandLineArguments: string[]) => + pipeline(commandLineArguments, map(toLower), find(startsWith("lens://"))); + +export default startMainApplicationInjectable; diff --git a/src/main/stop-services-and-exit-app.injectable.ts b/src/main/stop-services-and-exit-app.injectable.ts new file mode 100644 index 0000000000..25b4324aec --- /dev/null +++ b/src/main/stop-services-and-exit-app.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import exitAppInjectable from "./electron-app/features/exit-app.injectable"; +import clusterManagerInjectable from "./cluster-manager.injectable"; +import appEventBusInjectable from "../common/app-event-bus/app-event-bus.injectable"; +import loggerInjectable from "../common/logger.injectable"; +import closeAllWindowsInjectable from "./start-main-application/lens-window/hide-all-windows/close-all-windows.injectable"; + +const stopServicesAndExitAppInjectable = getInjectable({ + id: "stop-services-and-exit-app", + + instantiate: (di) => { + const exitApp = di.inject(exitAppInjectable); + const clusterManager = di.inject(clusterManagerInjectable); + const appEventBus = di.inject(appEventBusInjectable); + const logger = di.inject(loggerInjectable); + const closeAllWindows = di.inject(closeAllWindowsInjectable); + + return () => { + appEventBus.emit({ name: "service", action: "close" }); + closeAllWindows(); + clusterManager.stop(); + logger.info("SERVICE:QUIT"); + setTimeout(exitApp, 1000); + }; + }, +}); + +export default stopServicesAndExitAppInjectable; diff --git a/src/main/stores-apis-can-be-created.injectable.ts b/src/main/stores-apis-can-be-created.injectable.ts new file mode 100644 index 0000000000..6dc36f6702 --- /dev/null +++ b/src/main/stores-apis-can-be-created.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { storesAndApisCanBeCreatedInjectionToken } from "../common/k8s-api/stores-apis-can-be-created.token"; + +const storesAndApisCanBeCreatedInjectable = getInjectable({ + id: "create-stores-and-apis", + instantiate: () => false, + injectionToken: storesAndApisCanBeCreatedInjectionToken, +}); + +export default storesAndApisCanBeCreatedInjectable; diff --git a/src/main/theme/broadcast-theme-change/broadcast-theme-change.injectable.ts b/src/main/theme/broadcast-theme-change/broadcast-theme-change.injectable.ts new file mode 100644 index 0000000000..98be53d748 --- /dev/null +++ b/src/main/theme/broadcast-theme-change/broadcast-theme-change.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { reaction } from "mobx"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import { setNativeThemeChannel } from "../../../common/ipc/native-theme"; +import operatingSystemThemeInjectable from "../operating-system-theme.injectable"; +import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable"; + +const broadcastThemeChangeInjectable = getInjectable({ + id: "broadcast-theme-change", + + instantiate: (di) => { + const currentTheme = di.inject(operatingSystemThemeInjectable); + const broadcastMessage = di.inject(broadcastMessageInjectable); + + return getStartableStoppable("broadcast-theme-change", () => + reaction(() => currentTheme.get(), (theme) => { + broadcastMessage(setNativeThemeChannel, theme); + }), + ); + }, +}); + +export default broadcastThemeChangeInjectable; diff --git a/src/main/theme/broadcast-theme-change/start-broadcasting-theme-change.injectable.ts b/src/main/theme/broadcast-theme-change/start-broadcasting-theme-change.injectable.ts new file mode 100644 index 0000000000..7794c07bd4 --- /dev/null +++ b/src/main/theme/broadcast-theme-change/start-broadcasting-theme-change.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import broadcastThemeChangeInjectable from "./broadcast-theme-change.injectable"; + +const startBroadcastingThemeChangeInjectable = getInjectable({ + id: "start-broadcasting-theme-change", + + instantiate: (di) => { + const broadcastThemeChange = di.inject(broadcastThemeChangeInjectable); + + return { + run: async () => { + await broadcastThemeChange.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startBroadcastingThemeChangeInjectable; diff --git a/src/main/theme/broadcast-theme-change/stop-broadcasting-theme-change.injectable.ts b/src/main/theme/broadcast-theme-change/stop-broadcasting-theme-change.injectable.ts new file mode 100644 index 0000000000..a5f922af13 --- /dev/null +++ b/src/main/theme/broadcast-theme-change/stop-broadcasting-theme-change.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import broadcastThemeChangeInjectable from "./broadcast-theme-change.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopBroadcastingThemeChangeInjectable = getInjectable({ + id: "stop-broadcasting-theme-change", + + instantiate: (di) => { + const broadcastThemeChange = di.inject(broadcastThemeChangeInjectable); + + return { + run: async () => { + await broadcastThemeChange.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopBroadcastingThemeChangeInjectable; diff --git a/src/main/theme/operating-system-theme-state.injectable.ts b/src/main/theme/operating-system-theme-state.injectable.ts new file mode 100644 index 0000000000..cae3905b2e --- /dev/null +++ b/src/main/theme/operating-system-theme-state.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import getElectronThemeInjectable from "../electron-app/features/get-electron-theme.injectable"; + +export type Theme = "dark" | "light"; + +const operatingSystemThemeStateInjectable = getInjectable({ + id: "operating-system-theme-state", + + instantiate: (di) => { + const getElectronTheme = di.inject(getElectronThemeInjectable); + const defaultTheme = getElectronTheme(); + + return observable.box( + defaultTheme, + ); + }, +}); + +export default operatingSystemThemeStateInjectable; diff --git a/src/main/theme/operating-system-theme.injectable.ts b/src/main/theme/operating-system-theme.injectable.ts new file mode 100644 index 0000000000..8ec5fcf445 --- /dev/null +++ b/src/main/theme/operating-system-theme.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import operatingSystemThemeStateInjectable from "./operating-system-theme-state.injectable"; + +const operatingSystemThemeInjectable = getInjectable({ + id: "operating-system-theme", + + instantiate: (di) => { + const currentThemeState = di.inject(operatingSystemThemeStateInjectable); + + return computed(() => currentThemeState.get()); + }, +}); + +export default operatingSystemThemeInjectable; diff --git a/src/main/theme/sync-theme-from-os/start-syncing-theme-from-operating-system.injectable.ts b/src/main/theme/sync-theme-from-os/start-syncing-theme-from-operating-system.injectable.ts new file mode 100644 index 0000000000..9bf9c5fe49 --- /dev/null +++ b/src/main/theme/sync-theme-from-os/start-syncing-theme-from-operating-system.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import syncThemeFromOperatingSystemInjectable from "../../electron-app/features/sync-theme-from-operating-system.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; + +const startSyncingThemeFromOperatingSystemInjectable = getInjectable({ + id: "start-syncing-theme-from-operating-system", + + instantiate: (di) => { + const syncTheme = di.inject(syncThemeFromOperatingSystemInjectable); + + return { + run: async () => { + await syncTheme.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startSyncingThemeFromOperatingSystemInjectable; diff --git a/src/main/theme/sync-theme-from-os/stop-syncing-theme-from-operating-system.injectable.ts b/src/main/theme/sync-theme-from-os/stop-syncing-theme-from-operating-system.injectable.ts new file mode 100644 index 0000000000..08657281c2 --- /dev/null +++ b/src/main/theme/sync-theme-from-os/stop-syncing-theme-from-operating-system.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import syncThemeFromOperatingSystemInjectable from "../../electron-app/features/sync-theme-from-operating-system.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopSyncingThemeFromOperatingSystemInjectable = getInjectable({ + id: "stop-syncing-theme-from-operating-system", + + instantiate: (di) => { + const syncTheme = di.inject(syncThemeFromOperatingSystemInjectable); + + return { + run: async () => { + await syncTheme.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopSyncingThemeFromOperatingSystemInjectable; diff --git a/src/main/tray/electron-tray/electron-tray.injectable.ts b/src/main/tray/electron-tray/electron-tray.injectable.ts new file mode 100644 index 0000000000..409e7abf3f --- /dev/null +++ b/src/main/tray/electron-tray/electron-tray.injectable.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { Menu, Tray } from "electron"; +import packageJsonInjectable from "../../../common/vars/package-json.injectable"; +import logger from "../../logger"; +import { TRAY_LOG_PREFIX } from "../tray"; +import showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; +import type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { isEmpty, map, filter } from "lodash/fp"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import trayIconPathInjectable from "../tray-icon-path.injectable"; + +const electronTrayInjectable = getInjectable({ + id: "electron-tray", + + instantiate: (di) => { + const packageJson = di.inject(packageJsonInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const isWindows = di.inject(isWindowsInjectable); + const logger = di.inject(loggerInjectable); + const trayIconPath = di.inject(trayIconPathInjectable); + + let tray: Tray; + + return { + start: () => { + tray = new Tray(trayIconPath); + + tray.setToolTip(packageJson.description); + tray.setIgnoreDoubleClickEvents(true); + + if (isWindows) { + tray.on("click", () => { + showApplicationWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); + }); + } + }, + + stop: () => { + tray.destroy(); + }, + + setMenuItems: (items: TrayMenuItem[]) => { + pipeline( + items, + convertToElectronMenuTemplate, + Menu.buildFromTemplate, + + (template) => { + tray.setContextMenu(template); + }, + ); + }, + }; + }, + + causesSideEffects: true, +}); + +export default electronTrayInjectable; + +const convertToElectronMenuTemplate = (trayMenuItems: TrayMenuItem[]) => { + const _toTrayMenuOptions = (parentId: string | null) => + pipeline( + trayMenuItems, + + filter((item) => item.parentId === parentId), + + map( + (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { + if (trayMenuItem.separator) { + return { id: trayMenuItem.id, type: "separator" }; + } + + const childItems = _toTrayMenuOptions(trayMenuItem.id); + + return { + id: trayMenuItem.id, + label: trayMenuItem.label?.get(), + enabled: trayMenuItem.enabled.get(), + toolTip: trayMenuItem.tooltip, + + ...(isEmpty(childItems) + ? { + type: "normal", + submenu: _toTrayMenuOptions(trayMenuItem.id), + + click: () => { + try { + trayMenuItem.click?.(); + } catch (error) { + logger.error( + `${TRAY_LOG_PREFIX}: clicking item "${trayMenuItem.id} failed."`, + { error }, + ); + } + }, + } + : { + type: "submenu", + submenu: _toTrayMenuOptions(trayMenuItem.id), + }), + + }; + }, + ), + ); + + return _toTrayMenuOptions(null); +}; diff --git a/src/main/tray/electron-tray/start-tray.injectable.ts b/src/main/tray/electron-tray/start-tray.injectable.ts new file mode 100644 index 0000000000..1a223ac3a5 --- /dev/null +++ b/src/main/tray/electron-tray/start-tray.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import electronTrayInjectable from "./electron-tray.injectable"; + +const startTrayInjectable = getInjectable({ + id: "start-tray", + + instantiate: (di) => { + const electronTray = di.inject(electronTrayInjectable); + + return { + run: () => { + electronTray.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startTrayInjectable; diff --git a/src/main/tray/electron-tray/stop-tray.injectable.ts b/src/main/tray/electron-tray/stop-tray.injectable.ts new file mode 100644 index 0000000000..f66ffb3a64 --- /dev/null +++ b/src/main/tray/electron-tray/stop-tray.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import electronTrayInjectable from "./electron-tray.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import stopReactiveTrayMenuItemsInjectable from "../reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable"; + +const stopTrayInjectable = getInjectable({ + id: "stop-tray", + + instantiate: (di) => { + const electronTray = di.inject(electronTrayInjectable); + + return { + run: () => { + electronTray.stop(); + }, + + runAfter: di.inject(stopReactiveTrayMenuItemsInjectable), + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopTrayInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts new file mode 100644 index 0000000000..b11654393a --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import { autorun } from "mobx"; +import trayMenuItemsInjectable from "../tray-menu-item/tray-menu-items.injectable"; +import electronTrayInjectable from "../electron-tray/electron-tray.injectable"; + +const reactiveTrayMenuItemsInjectable = getInjectable({ + id: "reactive-tray-menu-items", + + instantiate: (di) => { + const electronTray = di.inject(electronTrayInjectable); + const trayMenuItems = di.inject(trayMenuItemsInjectable); + + return getStartableStoppable("reactive-tray-menu-items", () => autorun(() => { + electronTray.setMenuItems(trayMenuItems.get()); + })); + }, +}); + +export default reactiveTrayMenuItemsInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/start-reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/start-reactive-tray-menu-items.injectable.ts new file mode 100644 index 0000000000..63025e6a9a --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/start-reactive-tray-menu-items.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import reactiveTrayMenuItemsInjectable from "./reactive-tray-menu-items.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import startTrayInjectable from "../electron-tray/start-tray.injectable"; + +const startReactiveTrayMenuItemsInjectable = getInjectable({ + id: "start-reactive-tray-menu-items", + + instantiate: (di) => { + const reactiveTrayMenuItems = di.inject(reactiveTrayMenuItemsInjectable); + + return { + run: async () => { + await reactiveTrayMenuItems.start(); + }, + + runAfter: di.inject(startTrayInjectable), + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startReactiveTrayMenuItemsInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable.ts new file mode 100644 index 0000000000..384cdc253a --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import reactiveTrayMenuItemsInjectable from "./reactive-tray-menu-items.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopReactiveTrayMenuItemsInjectable = getInjectable({ + id: "stop-reactive-tray-menu-items", + + instantiate: (di) => { + const reactiveTrayMenuItems = di.inject(reactiveTrayMenuItemsInjectable); + + return { + run: async () => { + await reactiveTrayMenuItems.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopReactiveTrayMenuItemsInjectable; diff --git a/src/main/tray/tray-icon-path.injectable.ts b/src/main/tray/tray-icon-path.injectable.ts new file mode 100644 index 0000000000..1eb4d13118 --- /dev/null +++ b/src/main/tray/tray-icon-path.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injectable"; +import staticFilesDirectoryInjectable from "../../common/vars/static-files-directory.injectable"; +import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; + +const trayIconPathInjectable = getInjectable({ + id: "tray-icon-path", + + instantiate: (di) => { + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); + const isDevelopment = di.inject(isDevelopmentInjectable); + + return getAbsolutePath( + staticFilesDirectory, + isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras + "trayIconTemplate.png", + ); + }, +}); + +export default trayIconPathInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts new file mode 100644 index 0000000000..5fb1a9f34f --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import productNameInjectable from "../../../app-paths/app-name/product-name.injectable"; +import showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import showAboutInjectable from "../../../menu/show-about.injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const aboutAppTrayItemInjectable = getInjectable({ + id: "about-app-tray-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const showAbout = di.inject(showAboutInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "about-app", + parentId: null, + orderNumber: 140, + label: computed(() => `About ${productName}`), + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + async () => { + await showApplicationWindow(); + + await showAbout(); + }, + + withErrorLoggingFor(() => "[TRAY]: Opening of show about failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default aboutAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts new file mode 100644 index 0000000000..ff19d7718a --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import productNameInjectable from "../../../app-paths/app-name/product-name.injectable"; +import showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import { computed } from "mobx"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const openAppTrayItemInjectable = getInjectable({ + id: "open-app-tray-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "open-app", + parentId: null, + label: computed(() => `Open ${productName}`), + orderNumber: 10, + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + async () => { + await showApplicationWindow(); + }, + + withErrorLoggingFor(() => "[TRAY]: Opening of application window failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default openAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts new file mode 100644 index 0000000000..8c062f6a29 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import navigateToPreferencesInjectable from "../../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; +import { computed } from "mobx"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; + +const openPreferencesTrayItemInjectable = getInjectable({ + id: "open-preferences-tray-item", + + instantiate: (di) => { + const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "open-preferences", + parentId: null, + label: computed(() => "Preferences"), + orderNumber: 20, + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + navigateToPreferences, + + withErrorLoggingFor(() => "[TRAY]: Opening of preferences failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default openPreferencesTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts new file mode 100644 index 0000000000..de83a92fe6 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; + +const quitAppSeparatorTrayItemInjectable = getInjectable({ + id: "quit-app-separator-tray-item", + + instantiate: () => ({ + id: "quit-app-separator", + parentId: null, + orderNumber: 149, + enabled: computed(() => true), + visible: computed(() => true), + separator: true, + }), + + injectionToken: trayMenuItemInjectionToken, +}); + +export default quitAppSeparatorTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts new file mode 100644 index 0000000000..894a823511 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; +import stopServicesAndExitAppInjectable from "../../../stop-services-and-exit-app.injectable"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; + +const quitAppTrayItemInjectable = getInjectable({ + id: "quit-app-tray-item", + + instantiate: (di) => { + const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "quit-app", + parentId: null, + orderNumber: 150, + label: computed(() => "Quit App"), + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + stopServicesAndExitApp, + + withErrorLoggingFor(() => "[TRAY]: Quitting application failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default quitAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts b/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts new file mode 100644 index 0000000000..f8e9d7c6cc --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; + +export interface TrayMenuItem { + id: string; + parentId: string | null; + orderNumber: number; + enabled: IComputedValue; + visible: IComputedValue; + + label?: IComputedValue; + click?: () => Promise | void; + tooltip?: string; + separator?: boolean; + extension?: LensMainExtension; +} + +export const trayMenuItemInjectionToken = getInjectionToken({ + id: "tray-menu-item", +}); diff --git a/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts new file mode 100644 index 0000000000..6cbd9e5d33 --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { flatMap, kebabCase } from "lodash/fp"; +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; +import type { TrayMenuItem } from "./tray-menu-item-injection-token"; +import { trayMenuItemInjectionToken } from "./tray-menu-item-injection-token"; +import type { TrayMenuRegistration } from "../tray-menu-registration"; +import { withErrorSuppression } from "../../../common/utils/with-error-suppression/with-error-suppression"; +import type { WithErrorLoggingFor } from "../../../common/utils/with-error-logging/with-error-logging.injectable"; +import withErrorLoggingInjectable from "../../../common/utils/with-error-logging/with-error-logging.injectable"; + +const trayMenuItemRegistratorInjectable = getInjectable({ + id: "tray-menu-item-registrator", + + instantiate: (di) => (extension, installationCounter) => { + const mainExtension = extension as LensMainExtension; + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + pipeline( + mainExtension.trayMenus, + + flatMap(toItemInjectablesFor(mainExtension, installationCounter, withErrorLoggingFor)), + + (injectables) => di.register(...injectables), + ); + }, + + injectionToken: extensionRegistratorInjectionToken, +}); + +export default trayMenuItemRegistratorInjectable; + +const toItemInjectablesFor = (extension: LensMainExtension, installationCounter: number, withErrorLoggingFor: WithErrorLoggingFor) => { + const _toItemInjectables = (parentId: string | null) => (registration: TrayMenuRegistration): Injectable[] => { + const trayItemId = registration.id || kebabCase(registration.label || ""); + const id = `${trayItemId}-tray-menu-item-for-extension-${extension.sanitizedExtensionId}-instance-${installationCounter}`; + + const parentInjectable = getInjectable({ + id, + + instantiate: () => ({ + id, + parentId, + orderNumber: 100, + + separator: registration.type === "separator", + + label: computed(() => registration.label || ""), + tooltip: registration.toolTip, + + click: pipeline( + () => { + registration.click?.(registration); + }, + + withErrorLoggingFor(() => `[TRAY]: Clicking of tray item "${trayItemId}" from extension "${extension.sanitizedExtensionId}" failed.`), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + + enabled: computed(() => !!registration.enabled), + visible: computed(() => true), + }), + + injectionToken: trayMenuItemInjectionToken, + }); + + const childMenuItems = registration.submenu || []; + + const childInjectables = childMenuItems.flatMap(_toItemInjectables(id)); + + return [ + parentInjectable, + ...childInjectables, + ]; + }; + + return _toItemInjectables(null); +}; + + diff --git a/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts new file mode 100644 index 0000000000..c29482007d --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; + + +import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; +import type { TrayMenuItem } from "./tray-menu-item-injection-token"; +import { trayMenuItemInjectionToken } from "./tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, overSome, sortBy } from "lodash/fp"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; + +const trayMenuItemsInjectable = getInjectable({ + id: "tray-menu-items", + + instantiate: (di) => { + const extensions = di.inject(mainExtensionsInjectable); + + return computed(() => { + const enabledExtensions = extensions.get(); + + return pipeline( + di.injectMany(trayMenuItemInjectionToken), + + filter((item) => + overSome([ + isNonExtensionItem, + isEnabledExtensionItemFor(enabledExtensions), + ])(item), + ), + + filter(item => item.visible.get()), + items => sortBy("orderNumber", items), + ); + }); + }, +}); + +const isNonExtensionItem = (item: TrayMenuItem) => !item.extension; + +const isEnabledExtensionItemFor = + (enabledExtensions: LensMainExtension[]) => (item: TrayMenuItem) => + !!enabledExtensions.find((extension) => extension === item.extension); + + +export default trayMenuItemsInjectable; diff --git a/src/main/tray/tray-menu-items.test.ts b/src/main/tray/tray-menu-items.test.ts index da9f2f2745..fb4f29f763 100644 --- a/src/main/tray/tray-menu-items.test.ts +++ b/src/main/tray/tray-menu-items.test.ts @@ -19,8 +19,6 @@ describe("tray-menu-items", () => { beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); - await di.runSetups(); - extensionsStub = new ObservableMap(); di.override( @@ -113,7 +111,7 @@ class SomeTestExtension extends LensMainExtension { isBundled: false, isCompatible: false, isEnabled: false, - manifest: { name: id, version: "some-version" }, + manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }}, manifestPath: "irrelevant", }); diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index e2c4425f42..4d7e39c344 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -7,46 +7,31 @@ import packageInfo from "../../../package.json"; import { Menu, Tray } from "electron"; import type { IComputedValue } from "mobx"; import { autorun } from "mobx"; -import { showAbout } from "../menu/menu"; -import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; -import type { WindowManager } from "../window-manager"; import logger from "../logger"; -import { isDevelopment, isWindows, productName, staticFilesDirectory } from "../../common/vars"; -import { exitApp } from "../exit-app"; +import { isWindows } from "../../common/vars"; import type { Disposer } from "../../common/utils"; -import { disposer, toJS } from "../../common/utils"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; -import path from "path"; +import { disposer } from "../../common/utils"; +import type { TrayMenuItem } from "./tray-menu-item/tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, isEmpty, map } from "lodash/fp"; - -const TRAY_LOG_PREFIX = "[TRAY]"; +export const TRAY_LOG_PREFIX = "[TRAY]"; // note: instance of Tray should be saved somewhere, otherwise it disappears -export let tray: Tray; - -function getTrayIconPath(): string { - return path.resolve( - staticFilesDirectory, - isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras - "trayIconTemplate.png", - ); -} +export let tray: Tray | null = null; export function initTray( - windowManager: WindowManager, - trayMenuItems: IComputedValue, - navigateToPreferences: () => void, + trayMenuItems: IComputedValue, + showApplicationWindow: () => Promise, + trayIconPath: string, ): Disposer { - const icon = getTrayIconPath(); - - tray = new Tray(icon); + tray = new Tray(trayIconPath); tray.setToolTip(packageInfo.description); tray.setIgnoreDoubleClickEvents(true); if (isWindows) { tray.on("click", () => { - windowManager - .ensureMainWindow() + showApplicationWindow() .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); }); } @@ -54,9 +39,11 @@ export function initTray( return disposer( autorun(() => { try { - const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences); + const options = toTrayMenuOptions(trayMenuItems.get()); - tray.setContextMenu(menu); + const menu = Menu.buildFromTemplate(options); + + tray?.setContextMenu(menu); } catch (error) { logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); } @@ -68,65 +55,45 @@ export function initTray( ); } -function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { - return { - ...trayItem, - submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, - click: trayItem.click ? () => { - trayItem.click(trayItem); - } : undefined, - }; -} +const toTrayMenuOptions = (trayMenuItems: TrayMenuItem[]) => { + const _toTrayMenuOptions = (parentId: string | null) => + pipeline( + trayMenuItems, -function createTrayMenu( - windowManager: WindowManager, - extensionTrayItems: TrayMenuRegistration[], - navigateToPreferences: () => void, -): Menu { - let template: Electron.MenuItemConstructorOptions[] = [ - { - label: `Open ${productName}`, - click() { - windowManager - .ensureMainWindow() - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }, - }, - { - label: "Preferences", - click() { - navigateToPreferences(); - }, - }, - ]; + filter((item) => item.parentId === parentId), - if (isAutoUpdateEnabled()) { - template.push({ - label: "Check for updates", - click() { - checkForUpdates() - .then(() => windowManager.ensureMainWindow()); - }, - }); - } + map( + (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { + if (trayMenuItem.separator) { + return { id: trayMenuItem.id, type: "separator" }; + } - template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); + const childItems = _toTrayMenuOptions(trayMenuItem.id); + + return { + id: trayMenuItem.id, + label: trayMenuItem.label?.get(), + enabled: trayMenuItem.enabled.get(), + toolTip: trayMenuItem.tooltip, + + ...(isEmpty(childItems) + ? { + type: "normal", + submenu: _toTrayMenuOptions(trayMenuItem.id), + + click: () => { + trayMenuItem.click?.(); + }, + } + : { + type: "submenu", + submenu: _toTrayMenuOptions(trayMenuItem.id), + }), + }; + }, + ), + ); + + return _toTrayMenuOptions(null); +}; - return Menu.buildFromTemplate(template.concat([ - { - label: `About ${productName}`, - click() { - windowManager.ensureMainWindow() - .then(showAbout) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); - }, - }, - { type: "separator" }, - { - label: "Quit App", - click() { - exitApp(); - }, - }, - ])); -} diff --git a/src/main/utils/__test__/update-channel.test.ts b/src/main/utils/__test__/update-channel.test.ts deleted file mode 100644 index e0c20e4707..0000000000 --- a/src/main/utils/__test__/update-channel.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { nextUpdateChannel } from "../update-channel"; - -describe("nextUpdateChannel", () => { - it("returns latest if current channel is latest", () => { - expect(nextUpdateChannel("latest", "latest")).toEqual("latest"); - }); - - it("returns beta if current channel is alpha", () => { - expect(nextUpdateChannel("alpha", "alpha")).toEqual("beta"); - expect(nextUpdateChannel("beta", "alpha")).toEqual("beta"); - expect(nextUpdateChannel("rc", "alpha")).toEqual("beta"); - expect(nextUpdateChannel("latest", "alpha")).toEqual("beta"); - }); - - it("returns latest if current channel is beta", () => { - expect(nextUpdateChannel("alpha", "beta")).toEqual("latest"); - expect(nextUpdateChannel("beta", "beta")).toEqual("latest"); - expect(nextUpdateChannel("rc", "beta")).toEqual("latest"); - expect(nextUpdateChannel("latest", "beta")).toEqual("latest"); - }); - - it("returns default if current channel is unknown", () => { - expect(nextUpdateChannel("alpha", "rc")).toEqual("alpha"); - expect(nextUpdateChannel("beta", "rc")).toEqual("beta"); - expect(nextUpdateChannel("rc", "rc")).toEqual("rc"); - expect(nextUpdateChannel("latest", "rc")).toEqual("latest"); - }); -}); diff --git a/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts new file mode 100644 index 0000000000..6b7fa9b8df --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IpcMainEvent } from "electron"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeParseJson } from "../../../../common/utils/tentative-parse-json"; + +const enlistMessageChannelListenerInjectable = getInjectable({ + id: "enlist-message-channel-listener-for-main", + + instantiate: (di) => { + const ipcMain = di.inject(ipcMainInjectable); + + return ({ channel, handler }) => { + const nativeOnCallback = (_: IpcMainEvent, message: unknown) => { + pipeline( + message, + tentativeParseJson, + handler, + ); + }; + + ipcMain.on(channel.id, nativeOnCallback); + + return () => { + ipcMain.off(channel.id, nativeOnCallback); + }; + }; + }, + + injectionToken: enlistMessageChannelListenerInjectionToken, +}); + +export default enlistMessageChannelListenerInjectable; diff --git a/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..3bd0398d8e --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { EnlistMessageChannelListener } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import type { IpcMain, IpcMainEvent } from "electron"; + +describe("enlist message channel listener in main", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcMainStub: IpcMain; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + onMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + on: onMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistMessageChannelListener = di.inject( + enlistMessageChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: jest.Mock; + let disposer: () => void; + + beforeEach(() => { + handlerMock = jest.fn(); + + disposer = enlistMessageChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(onMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when message arrives", () => { + beforeEach(() => { + onMock.mock.calls[0][1]({} as IpcMainEvent, "some-message"); + }); + + it("calls the handler with the message", () => { + expect(handlerMock).toHaveBeenCalledWith("some-message"); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts new file mode 100644 index 0000000000..6f118288f3 --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IpcMainInvokeEvent } from "electron"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import { enlistRequestChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeParseJson } from "../../../../common/utils/tentative-parse-json"; +import { tentativeStringifyJson } from "../../../../common/utils/tentative-stringify-json"; + +const enlistRequestChannelListenerInjectable = getInjectable({ + id: "enlist-request-channel-listener-for-main", + + instantiate: (di) => { + const ipcMain = di.inject(ipcMainInjectable); + + return ({ channel, handler }) => { + const nativeHandleCallback = (_: IpcMainInvokeEvent, request: unknown) => + pipeline(request, tentativeParseJson, handler, tentativeStringifyJson); + + ipcMain.handle(channel.id, nativeHandleCallback); + + return () => { + ipcMain.off(channel.id, nativeHandleCallback); + }; + }; + }, + + injectionToken: enlistRequestChannelListenerInjectionToken, +}); + +export default enlistRequestChannelListenerInjectable; diff --git a/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.test.ts b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.test.ts new file mode 100644 index 0000000000..12a5e9af74 --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { IpcMain, IpcMainInvokeEvent } from "electron"; +import type { EnlistRequestChannelListener } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; +import { enlistRequestChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; +import { getPromiseStatus } from "../../../../common/test-utils/get-promise-status"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; + +describe("enlist request channel listener in main", () => { + let enlistRequestChannelListener: EnlistRequestChannelListener; + let ipcMainStub: IpcMain; + let handleMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + handleMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + handle: handleMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistRequestChannelListener = di.inject( + enlistRequestChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: AsyncFnMock<(message: any) => any>; + let disposer: () => void; + + beforeEach(() => { + handlerMock = asyncFn(); + + disposer = enlistRequestChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(handleMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when request arrives", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = handleMock.mock.calls[0][1]( + {} as IpcMainInvokeEvent, + "some-request", + ); + }); + + it("calls the handler with the request", () => { + expect(handlerMock).toHaveBeenCalledWith("some-request"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when handler resolves with response, listener resolves with the response", () => { + beforeEach(async () => { + await handlerMock.resolve("some-response"); + }); + + it("resolves with the response", async () => { + const actual = await actualPromise; + + expect(actual).toBe('"some-response"'); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve(42); + + const actual = await actualPromise; + + expect(actual).toBe("42"); + }); + + it("given boolean as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve(true); + + const actual = await actualPromise; + + expect(actual).toBe("true"); + }); + + it("given object as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve({ some: "object" }); + + const actual = await actualPromise; + + expect(actual).toBe(JSON.stringify({ some: "object" })); + }); + }); + + it("given number as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/main/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts b/src/main/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts new file mode 100644 index 0000000000..96fea0a2f0 --- /dev/null +++ b/src/main/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import listeningOfChannelsInjectable from "../../../../common/utils/channel/listening-of-channels.injectable"; + +const startListeningOfChannelsInjectable = getInjectable({ + id: "start-listening-of-channels-main", + + instantiate: (di) => { + const listeningOfChannels = di.inject(listeningOfChannelsInjectable); + + return { + run: async () => { + await listeningOfChannels.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startListeningOfChannelsInjectable; diff --git a/src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts b/src/main/utils/channel/ipc-main/ipc-main.injectable.ts similarity index 100% rename from src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts rename to src/main/utils/channel/ipc-main/ipc-main.injectable.ts diff --git a/src/main/utils/channel/message-to-channel.injectable.ts b/src/main/utils/channel/message-to-channel.injectable.ts new file mode 100644 index 0000000000..00e588a16a --- /dev/null +++ b/src/main/utils/channel/message-to-channel.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { lensWindowInjectionToken } from "../../start-main-application/lens-window/application-window/lens-window-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { getInjectable } from "@ogre-tools/injectable"; +import { filter } from "lodash/fp"; +import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; +import { tentativeStringifyJson } from "../../../common/utils/tentative-stringify-json"; + +const messageToChannelInjectable = getInjectable({ + id: "message-to-channel", + + instantiate: (di) => { + const getAllLensWindows = () => di.injectMany(lensWindowInjectionToken); + + // TODO: Figure out way to improve typing in internals + // Notice that this should be injected using "messageToChannelInjectionToken" which is typed correctly. + return (channel: MessageChannel, message?: unknown) => { + const stringifiedMessage = tentativeStringifyJson(message); + + + const visibleWindows = pipeline( + getAllLensWindows(), + filter((lensWindow) => !!lensWindow.visible), + ); + + visibleWindows.forEach((lensWindow) => + lensWindow.send({ channel: channel.id, data: stringifiedMessage ? [stringifiedMessage] : [] }), + ); + }; + }, + + injectionToken: messageToChannelInjectionToken, +}); + +export default messageToChannelInjectable; diff --git a/src/main/utils/channel/message-to-channel.test.ts b/src/main/utils/channel/message-to-channel.test.ts new file mode 100644 index 0000000000..cf2fc46549 --- /dev/null +++ b/src/main/utils/channel/message-to-channel.test.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageToChannel } from "../../../common/utils/channel/message-to-channel-injection-token"; +import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import closeAllWindowsInjectable from "../../start-main-application/lens-window/hide-all-windows/close-all-windows.injectable"; +import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import createLensWindowInjectable from "../../start-main-application/lens-window/application-window/create-lens-window.injectable"; +import type { LensWindow } from "../../start-main-application/lens-window/application-window/lens-window-injection-token"; +import { lensWindowInjectionToken } from "../../start-main-application/lens-window/application-window/lens-window-injection-token"; +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import sendToChannelInElectronBrowserWindowInjectable from "../../start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; + +describe("message to channel from main", () => { + let messageToChannel: MessageToChannel; + let someTestWindow: LensWindow; + let someOtherTestWindow: LensWindow; + let sendToChannelInBrowserMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + sendToChannelInBrowserMock = jest.fn(); + di.override(sendToChannelInElectronBrowserWindowInjectable, () => sendToChannelInBrowserMock); + + someTestWindow = createTestWindow(di, "some-test-window-id"); + someOtherTestWindow = createTestWindow(di, "some-other-test-window-id"); + + messageToChannel = di.inject(messageToChannelInjectionToken); + + const closeAllWindows = di.inject(closeAllWindowsInjectable); + + closeAllWindows(); + }); + + it("given no visible windows, when messaging to channel, does not message to any window", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock).not.toHaveBeenCalled(); + }); + + describe("given visible window", () => { + beforeEach(async () => { + await someTestWindow.show(); + }); + + it("when messaging to channel, messages to window", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + ]); + }); + + it("given boolean as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, true); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ["true"], + }, + ], + ]); + }); + + it("given number as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, 42); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ["42"], + }, + ], + ]); + }); + + it("given object as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, { some: "object" }); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: [JSON.stringify({ some: "object" })], + }, + ], + ]); + }); + }); + + it("given multiple visible windows, when messaging to channel, messages to window", async () => { + await someTestWindow.show(); + await someOtherTestWindow.show(); + + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + ]); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel" }; + +const createTestWindow = (di: DiContainer, id: string) => { + const testWindowInjectable = getInjectable({ + id, + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + + return createLensWindow({ + id, + title: "Some test window", + defaultHeight: 42, + defaultWidth: 42, + getContentSource: () => ({ url: "some-content-url" }), + resizable: true, + windowFrameUtilitiesAreShown: false, + centered: false, + }); + }, + + injectionToken: lensWindowInjectionToken, + }); + + di.register(testWindowInjectable); + + return di.inject(testWindowInjectable); +}; diff --git a/src/renderer/components/+workloads-pods/pods-store.injectable.ts b/src/main/utils/command-line-arguments.injectable.ts similarity index 57% rename from src/renderer/components/+workloads-pods/pods-store.injectable.ts rename to src/main/utils/command-line-arguments.injectable.ts index 7515931f80..e27a3802d4 100644 --- a/src/renderer/components/+workloads-pods/pods-store.injectable.ts +++ b/src/main/utils/command-line-arguments.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { podsStore } from "./pods.store"; -const podsStoreInjectable = getInjectable({ - id: "pods-store", - instantiate: () => podsStore, +const commandLineArgumentsInjectable = getInjectable({ + id: "command-line-arguments", + instantiate: () => process.argv, causesSideEffects: true, }); -export default podsStoreInjectable; +export default commandLineArgumentsInjectable; diff --git a/src/main/utils/get-port.ts b/src/main/utils/get-port.ts index 372477bb09..6ce76f6795 100644 --- a/src/main/utils/get-port.ts +++ b/src/main/utils/get-port.ts @@ -12,7 +12,15 @@ interface GetPortArgs { * Should be case insensitive * Must have a named matching group called `address` */ - lineRegex: RegExp; + lineRegex: { + match: (line: string) => { + matched: boolean; + groups?: { + address?: string; + }; + raw?: RegExpExecArray; + }; + }; /** * Called when the port is found */ @@ -35,15 +43,15 @@ export function getPortFrom(stream: Readable, args: GetPortArgs): Promise((resolve, reject) => { - const handler = (data: any) => { - const logItem: string = data.toString(); - const match = logItem.match(args.lineRegex); + const handler = (data: unknown) => { + const logItem = String(data); + const match = args.lineRegex.match(logItem); logLines.push(logItem); - if (match) { + if (match.matched) { // use unknown protocol so that there is no default port - const addr = new URLParse(`s://${match.groups.address.trim()}`); + const addr = new URLParse(`s://${match.groups?.address?.trim()}`); args.onFind?.(); stream.off("data", handler); diff --git a/src/main/utils/sync-box/sync-box-initial-value-channel-listener.injectable.ts b/src/main/utils/sync-box/sync-box-initial-value-channel-listener.injectable.ts new file mode 100644 index 0000000000..5eb043291a --- /dev/null +++ b/src/main/utils/sync-box/sync-box-initial-value-channel-listener.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import syncBoxInitialValueChannelInjectable from "../../../common/utils/sync-box/sync-box-initial-value-channel.injectable"; +import { syncBoxInjectionToken } from "../../../common/utils/sync-box/sync-box-injection-token"; +import { requestChannelListenerInjectionToken } from "../../../common/utils/channel/request-channel-listener-injection-token"; + +const syncBoxInitialValueChannelListenerInjectable = getInjectable({ + id: "sync-box-initial-value-channel-listener", + + instantiate: (di) => { + const channel = di.inject(syncBoxInitialValueChannelInjectable); + const syncBoxes = di.injectMany(syncBoxInjectionToken); + + return { + channel, + + handler: () => + syncBoxes.map((box) => ({ + id: box.id, + value: box.value.get(), + })), + }; + }, + + injectionToken: requestChannelListenerInjectionToken, +}); + +export default syncBoxInitialValueChannelListenerInjectable; diff --git a/src/main/utils/update-channel.ts b/src/main/utils/update-channel.ts deleted file mode 100644 index aabd9a9216..0000000000 --- a/src/main/utils/update-channel.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -/** - * Compute the next update channel from the current updating channel - * @param defaultChannel The default (initial) channel to check - * @param channel The current channel that did not have a new version associated with it - * @returns The channel name of the next release version - */ -export function nextUpdateChannel(defaultChannel: string, channel: string): string { - switch (channel) { - case "alpha": - return "beta"; - case "beta": - return "latest"; // there is no RC currently - default: - return defaultChannel; - } -} diff --git a/src/main/window-manager.injectable.ts b/src/main/window-manager.injectable.ts deleted file mode 100644 index 6a1f54a660..0000000000 --- a/src/main/window-manager.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { WindowManager } from "./window-manager"; - -const windowManagerInjectable = getInjectable({ - id: "window-manager", - - instantiate: () => { - WindowManager.resetInstance(); - - return WindowManager.createInstance(); - }, - - causesSideEffects: true, -}); - -export default windowManagerInjectable; diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts deleted file mode 100644 index 260a82ccc0..0000000000 --- a/src/main/window-manager.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { ClusterId } from "../common/cluster-types"; -import { makeObservable, observable } from "mobx"; -import { app, BrowserWindow, dialog, ipcMain, webContents } from "electron"; -import windowStateKeeper from "electron-window-state"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { ipcMainOn } from "../common/ipc"; -import { delay, iter, Singleton, openBrowser } from "../common/utils"; -import type { ClusterFrameInfo } from "../common/cluster-frames"; -import { clusterFrameMap } from "../common/cluster-frames"; -import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; -import logger from "./logger"; -import { isMac, productName } from "../common/vars"; -import { LensProxy } from "./lens-proxy"; -import { bundledExtensionsLoaded } from "../common/ipc/extension-handling"; - -function isHideable(window: BrowserWindow | null): boolean { - return Boolean(window && !window.isDestroyed()); -} - -export interface SendToViewArgs { - channel: string; - frameInfo?: ClusterFrameInfo; - data?: any[]; -} - -export class WindowManager extends Singleton { - public mainContentUrl = `http://localhost:${LensProxy.getInstance().port}`; - - protected mainWindow: BrowserWindow; - protected splashWindow: BrowserWindow; - protected windowState: windowStateKeeper.State; - protected disposers: Record = {}; - - @observable activeClusterId: ClusterId; - - constructor() { - super(); - makeObservable(this); - this.bindEvents(); - } - - private async initMainWindow(showSplash: boolean) { - // Manage main window size and position with state persistence - if (!this.windowState) { - this.windowState = windowStateKeeper({ - defaultHeight: 900, - defaultWidth: 1440, - }); - } - - if (!this.mainWindow) { - // show icon in dock (mac-os only) - app.dock?.show(); - - const { width, height, x, y } = this.windowState; - - this.mainWindow = new BrowserWindow({ - x, y, width, height, - title: productName, - show: false, - minWidth: 700, // accommodate 800 x 600 display minimum - minHeight: 500, // accommodate 800 x 600 display minimum - titleBarStyle: isMac ? "hiddenInset" : "hidden", - frame: isMac, - backgroundColor: "#1e2124", - webPreferences: { - nodeIntegration: true, - nodeIntegrationInSubFrames: true, - webviewTag: true, - contextIsolation: false, - nativeWindowOpen: false, - }, - }); - this.windowState.manage(this.mainWindow); - - // open external links in default browser (target=_blank, window.open) - this.mainWindow - .on("focus", () => { - appEventBus.emit({ name: "app", action: "focus" }); - }) - .on("blur", () => { - appEventBus.emit({ name: "app", action: "blur" }); - }) - .on("closed", () => { - // clean up - this.windowState.unmanage(); - this.mainWindow = null; - this.splashWindow = null; - app.dock?.hide(); // hide icon in dock (mac-os) - }) - .webContents - .on("dom-ready", () => { - appEventBus.emit({ name: "app", action: "dom-ready" }); - }) - .on("did-fail-load", (_event, code, desc) => { - logger.error(`[WINDOW-MANAGER]: Failed to load Main window`, { code, desc }); - }) - .on("did-finish-load", () => { - logger.info("[WINDOW-MANAGER]: Main window loaded"); - }) - .on("will-attach-webview", (event, webPreferences, params) => { - logger.debug("[WINDOW-MANAGER]: Attaching webview"); - // Following is security recommendations because we allow webview tag (webviewTag: true) - // suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation - // and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups - - if (webPreferences.preload) { - logger.warn("[WINDOW-MANAGER]: Strip away preload scripts of webview"); - delete webPreferences.preload; - } - - // @ts-expect-error some electron version uses webPreferences.preloadURL/webPreferences.preload - if (webPreferences.preloadURL) { - logger.warn("[WINDOW-MANAGER]: Strip away preload scripts of webview"); - delete webPreferences.preload; - } - - if (params.allowpopups) { - logger.warn("[WINDOW-MANAGER]: We do not allow allowpopups props, stop webview from renderer"); - - // event.preventDefault() will destroy the guest page. - event.preventDefault(); - - return; - } - - // Always disable Node.js integration for all webviews - webPreferences.nodeIntegration = false; - }) - .setWindowOpenHandler((details) => { - openBrowser(details.url).catch(error => { - logger.error("[WINDOW-MANAGER]: failed to open browser", { error }); - }); - - return { action: "deny" }; - }); - } - - try { - if (showSplash) await this.showSplash(); - logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainContentUrl} ...`); - await this.mainWindow.loadURL(this.mainContentUrl); - } catch (error) { - logger.error("Loading main window failed", { error }); - dialog.showErrorBox("ERROR!", error.toString()); - } - } - - protected bindEvents() { - // track visible cluster from ui - ipcMainOn(IpcRendererNavigationEvents.CLUSTER_VIEW_CURRENT_ID, (event, clusterId: ClusterId) => { - this.activeClusterId = clusterId; - }); - } - - async ensureMainWindow(showSplash = true): Promise { - // This needs to be ready to hear the IPC message before the window is loaded - let viewHasLoaded = Promise.resolve(); - - if (!this.mainWindow) { - viewHasLoaded = new Promise(resolve => { - ipcMain.once(bundledExtensionsLoaded, () => resolve()); - }); - await this.initMainWindow(showSplash); - } - - try { - await viewHasLoaded; - await delay(50); // wait just a bit longer to let the first round of rendering happen - logger.info("[WINDOW-MANAGER]: Main window has reported that it has loaded"); - - this.mainWindow.show(); - this.splashWindow?.close(); - this.splashWindow = undefined; - setTimeout(() => { - appEventBus.emit({ name: "app", action: "start" }); - }, 1000); - } catch (error) { - logger.error(`Showing main window failed: ${error.stack || error}`); - dialog.showErrorBox("ERROR!", error.toString()); - } - - return this.mainWindow; - } - - sendToView({ channel, frameInfo, data = [] }: SendToViewArgs) { - if (frameInfo) { - this.mainWindow.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data); - } else { - this.mainWindow.webContents.send(channel, ...data); - } - } - - async navigateExtension(extId: string, pageId?: string, params?: Record, frameId?: number) { - await this.ensureMainWindow(); - - const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId); - - this.sendToView({ - channel: "extension:navigate", - frameInfo, - data: [extId, pageId, params], - }); - } - - async navigate(url: string, frameId?: number) { - await this.ensureMainWindow(); - - this.navigateSync(url, frameId); - } - - navigateSync(url: string, frameId?: number) { - const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId); - const channel = frameInfo - ? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER - : IpcRendererNavigationEvents.NAVIGATE_IN_APP; - - this.sendToView({ - channel, - frameInfo, - data: [url], - }); - } - - reload() { - const frameInfo = clusterFrameMap.get(this.activeClusterId); - - if (frameInfo) { - this.sendToView({ channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo }); - } else { - webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => { - wc.reload(); - wc.clearHistory(); - }); - } - } - - async showSplash() { - if (!this.splashWindow) { - this.splashWindow = new BrowserWindow({ - width: 500, - height: 300, - backgroundColor: "#1e2124", - center: true, - frame: false, - resizable: false, - show: false, - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - nodeIntegrationInSubFrames: true, - nativeWindowOpen: true, - }, - }); - await this.splashWindow.loadURL("static://splash.html"); - } - this.splashWindow.show(); - } - - hide() { - if (isHideable(this.mainWindow)) { - this.mainWindow.hide(); - } - - if (isHideable(this.splashWindow)) { - this.splashWindow.hide(); - } - } - - destroy() { - this.mainWindow.destroy(); - this.splashWindow.destroy(); - this.mainWindow = null; - this.splashWindow = null; - Object.entries(this.disposers).forEach(([name, dispose]) => { - dispose(); - delete this.disposers[name]; - }); - } -} diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index d399c9f324..09dbd1fd61 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -21,7 +21,7 @@ import getCustomKubeConfigDirectoryInjectable from "../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; interface Pre360ClusterModel extends ClusterModel { - kubeConfig: string; + kubeConfig?: string; } export default { @@ -47,6 +47,10 @@ export default { try { const absPath = getCustomKubeConfigDirectory(clusterModel.id); + if (!clusterModel.kubeConfig) { + continue; + } + // take the embedded kubeconfig and dump it into a file fse.writeFileSync(absPath, clusterModel.kubeConfig, { encoding: "utf-8", mode: 0o600 }); @@ -75,7 +79,7 @@ export default { } } catch (error) { migrationLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); - delete clusterModel.preferences.icon; + delete clusterModel.preferences?.icon; } migratedClusters.push(clusterModel); diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts index a9fc759ee4..9172969a85 100644 --- a/src/migrations/cluster-store/5.0.0-beta.10.ts +++ b/src/migrations/cluster-store/5.0.0-beta.10.ts @@ -9,6 +9,7 @@ import type { ClusterModel } from "../../common/cluster-types"; import type { MigrationDeclaration } from "../helpers"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isErrnoException } from "../../common/utils"; interface Pre500WorkspaceStoreModel { workspaces: { @@ -35,15 +36,18 @@ export default { const clusters: ClusterModel[] = store.get("clusters") ?? []; for (const cluster of clusters) { - if (cluster.workspace && workspaces.has(cluster.workspace)) { - cluster.labels ??= {}; - cluster.labels.workspace = workspaces.get(cluster.workspace); + if (cluster.workspace) { + const workspace = workspaces.get(cluster.workspace); + + if (workspace) { + (cluster.labels ??= {}).workspace = workspace; + } } } store.set("clusters", clusters); } catch (error) { - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { + if (isErrnoException(error) && !(error.code === "ENOENT" && error.path?.endsWith("lens-workspace-store.json"))) { // ignore lens-workspace-store.json being missing throw error; } diff --git a/src/migrations/cluster-store/5.0.0-beta.13.ts b/src/migrations/cluster-store/5.0.0-beta.13.ts index d386d006d2..dc85a04718 100644 --- a/src/migrations/cluster-store/5.0.0-beta.13.ts +++ b/src/migrations/cluster-store/5.0.0-beta.13.ts @@ -12,6 +12,7 @@ import { moveSync, removeSync } from "fs-extra"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isDefined } from "../../common/utils"; function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences { if (left.prometheus && left.prometheusProvider) { @@ -50,12 +51,14 @@ function mergeLabels(left: Record, right: Record }; } -function mergeSet(...iterables: Iterable[]): string[] { +function mergeSet(...iterables: Iterable[]): string[] { const res = new Set(); for (const iterable of iterables) { for (const val of iterable) { - res.add(val); + if (val) { + res.add(val); + } } } @@ -104,16 +107,17 @@ export default { for (const { id: oldId, ...cluster } of oldClusters) { const newId = generateNewIdFor(cluster); + const newCluster = clusters.get(newId); - if (clusters.has(newId)) { + if (newCluster) { migrationLog(`Duplicate entries for ${newId}`, { oldId }); - clusters.set(newId, mergeClusterModel(clusters.get(newId), cluster)); + clusters.set(newId, mergeClusterModel(newCluster, cluster)); } else { migrationLog(`First entry for ${newId}`, { oldId }); clusters.set(newId, { ...cluster, id: newId, - workspaces: [cluster.workspace].filter(Boolean), + workspaces: [cluster.workspace].filter(isDefined), }); moveStorageFolder({ folder, newId, oldId }); } diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts index 3515a927bd..c8f99dc4f6 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -5,7 +5,7 @@ // Cleans up a store that had the state related data stored import type { MigrationDeclaration } from "../helpers"; -import { getEmptyHotbar } from "../../common/hotbar-types"; +import { getEmptyHotbar } from "../../common/hotbars/types"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import catalogCatalogEntityInjectable from "../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; diff --git a/src/migrations/hotbar-store/5.0.0-alpha.2.ts b/src/migrations/hotbar-store/5.0.0-alpha.2.ts index 8291bb267d..0885caa1c1 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.2.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.2.ts @@ -4,7 +4,7 @@ */ // Cleans up a store that had the state related data stored -import type { Hotbar } from "../../common/hotbar-types"; +import type { Hotbar } from "../../common/hotbars/types"; import * as uuid from "uuid"; import type { MigrationDeclaration } from "../helpers"; @@ -14,9 +14,9 @@ export default { const rawHotbars = store.get("hotbars"); const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; - store.set("hotbars", hotbars.map((hotbar) => ({ - id: uuid.v4(), - ...hotbar, + store.set("hotbars", hotbars.map(({ id, ...rest }) => ({ + id: id || uuid.v4(), + ...rest, }))); }, } as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts index 15466b993e..95a4c616d4 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.10.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.10.ts @@ -8,16 +8,15 @@ import { isNull } from "lodash"; import path from "path"; import * as uuid from "uuid"; import type { ClusterStoreModel } from "../../common/cluster-store/cluster-store"; -import type { Hotbar, HotbarItem } from "../../common/hotbar-types"; -import { defaultHotbarCells, getEmptyHotbar } from "../../common/hotbar-types"; +import type { Hotbar, HotbarItem } from "../../common/hotbars/types"; +import { defaultHotbarCells, getEmptyHotbar } from "../../common/hotbars/types"; import type { MigrationDeclaration } from "../helpers"; import { migrationLog } from "../helpers"; import { generateNewIdFor } from "../utils"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import catalogCatalogEntityInjectable - from "../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import catalogCatalogEntityInjectable from "../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { isDefined, isErrnoException } from "../../common/utils"; interface Pre500WorkspaceStoreModel { workspaces: { @@ -58,7 +57,7 @@ export default { try { const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); - const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); + const { clusters = [] }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar for (const { id, name } of workspaceStoreData.workspaces) { @@ -78,14 +77,14 @@ export default { workspaceHotbars.set("default", { name, id, - items: items.filter(Boolean), + items: items.filter(isDefined), }); } for (const cluster of clusters) { const uid = generateNewIdFor(cluster); - for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(Boolean)) { + for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(isDefined)) { const workspaceHotbar = workspaceHotbars.get(workspaceId); if (!workspaceHotbar) { @@ -99,7 +98,7 @@ export default { workspaceHotbar.items.push({ entity: { uid: generateNewIdFor(cluster), - name: cluster.preferences.clusterName || cluster.contextName, + name: cluster.preferences?.clusterName || cluster.contextName, }, }); } @@ -156,7 +155,7 @@ export default { } catch (error) { // ignore files being missing - if (error.code !== "ENOENT") { + if (isErrnoException(error) && error.code !== "ENOENT") { throw error; } } diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts index bb7ceb2567..39f8c054db 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -3,8 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { Hotbar } from "../../common/hotbar-types"; -import { catalogEntityRegistry } from "../../main/catalog"; +import type { Hotbar } from "../../common/hotbars/types"; +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; import type { MigrationDeclaration } from "../helpers"; export default { @@ -12,18 +13,25 @@ export default { run(store) { const rawHotbars = store.get("hotbars"); const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; + const di = getLegacyGlobalDiForExtensionApi(); + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); for (const hotbar of hotbars) { for (let i = 0; i < hotbar.items.length; i += 1) { const item = hotbar.items[i]; - const entity = catalogEntityRegistry.items.find((entity) => entity.getId() === item?.entity.uid); + + if (!item) { + continue; + } + + const entity = catalogEntityRegistry.findById(item.entity.uid); if (!entity) { // Clear disabled item hotbar.items[i] = null; } else { // Save additional data - hotbar.items[i].entity = { + item.entity = { ...item.entity, name: entity.metadata.name, source: entity.metadata.source, diff --git a/src/migrations/user-store/5.0.3-beta.1.ts b/src/migrations/user-store/5.0.3-beta.1.ts index f46949942c..40f93312cf 100644 --- a/src/migrations/user-store/5.0.3-beta.1.ts +++ b/src/migrations/user-store/5.0.3-beta.1.ts @@ -10,7 +10,7 @@ import type { ClusterStoreModel } from "../../common/cluster-store/cluster-store import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store"; import type { MigrationDeclaration } from "../helpers"; import { migrationLog } from "../helpers"; -import { isLogicalChildPath } from "../../common/utils"; +import { isErrnoException, isLogicalChildPath } from "../../common/utils"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; @@ -69,7 +69,7 @@ export default { migrationLog("Final list of synced paths", updatedSyncEntries); store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries }); } catch (error) { - if (error.code !== "ENOENT") { + if (isErrnoException(error) && error.code !== "ENOENT") { // ignore files being missing throw error; } diff --git a/src/migrations/user-store/file-name-migration.ts b/src/migrations/user-store/file-name-migration.ts deleted file mode 100644 index c9075aaa6f..0000000000 --- a/src/migrations/user-store/file-name-migration.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import fse from "fs-extra"; -import path from "path"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -export function fileNameMigration() { - const di = getLegacyGlobalDiForExtensionApi(); - - const userDataPath = di.inject(directoryForUserDataInjectable); - const configJsonPath = path.join(userDataPath, "config.json"); - const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); - - try { - fse.moveSync(configJsonPath, lensUserStoreJsonPath); - } catch (error) { - if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory) - return; // file already moved - } else if (error.message === "dest already exists.") { - fse.removeSync(configJsonPath); - } else { - // pass other errors along - throw error; - } - } -} diff --git a/src/migrations/user-store/index.ts b/src/migrations/user-store/index.ts index 797868954b..557d367c63 100644 --- a/src/migrations/user-store/index.ts +++ b/src/migrations/user-store/index.ts @@ -10,11 +10,6 @@ import { joinMigrations } from "../helpers"; import version210Beta4 from "./2.1.0-beta.4"; import version500Alpha3 from "./5.0.0-alpha.3"; import version503Beta1 from "./5.0.3-beta.1"; -import { fileNameMigration } from "./file-name-migration"; - -export { - fileNameMigration, -}; export default joinMigrations( version210Beta4, diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index aeb537267e..08dacb8f5a 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -3,21 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { CatalogEntityRegistry } from "../catalog-entity-registry"; -import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; -import type { CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; import { CatalogCategory } from "../catalog-entity"; import { KubernetesCluster, WebLink } from "../../../common/catalog-entities"; import { observable } from "mobx"; +import type { CatalogCategoryRegistry } from "../../../common/catalog"; +import { categoryVersion } from "../../../common/catalog"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { CatalogEntityRegistry } from "../catalog/entity/registry"; +import catalogEntityRegistryInjectable from "../catalog/entity/registry.injectable"; +import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable"; -class TestCatalogEntityRegistry extends CatalogEntityRegistry { - replaceItems(items: Array) { - this.updateItems(items); - } -} class FooBarCategory extends CatalogCategory { - public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly apiVersion = "entityRegistry.k8slens.dev/v1alpha1"; public readonly kind = "CatalogCategory"; public metadata = { name: "FooBars", @@ -26,10 +24,7 @@ class FooBarCategory extends CatalogCategory { public spec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: WebLink, - }, + categoryVersion("v1alpha1", WebLink), ], names: { kind: "FooBar", @@ -81,9 +76,18 @@ const entitykc = new KubernetesCluster({ }); describe("CatalogEntityRegistry", () => { + let entityRegistry: CatalogEntityRegistry; + let catalogCategoryRegistry: CatalogCategoryRegistry; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + entityRegistry = di.inject(catalogEntityRegistryInjectable); + catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable); + }); + describe("updateItems", () => { it("adds new catalog item", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -99,8 +103,8 @@ describe("CatalogEntityRegistry", () => { spec: {}, }]; - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); + entityRegistry.updateItems(items); + expect(entityRegistry.items.get().length).toEqual(1); items.push({ apiVersion: "entity.k8slens.dev/v1alpha1", @@ -117,12 +121,11 @@ describe("CatalogEntityRegistry", () => { spec: {}, }); - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(2); + entityRegistry.updateItems(items); + expect(entityRegistry.items.get().length).toEqual(2); }); it("updates existing items", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -138,19 +141,18 @@ describe("CatalogEntityRegistry", () => { spec: {}, }]; - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); - expect(catalog.items[0].status.phase).toEqual("disconnected"); + entityRegistry.updateItems(items); + expect(entityRegistry.items.get().length).toEqual(1); + expect(entityRegistry.items.get()[0].status.phase).toEqual("disconnected"); items[0].status.phase = "connected"; - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); - expect(catalog.items[0].status.phase).toEqual("connected"); + entityRegistry.updateItems(items); + expect(entityRegistry.items.get().length).toEqual(1); + expect(entityRegistry.items.get()[0].status.phase).toEqual("connected"); }); it("updates activeEntity", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -166,17 +168,16 @@ describe("CatalogEntityRegistry", () => { spec: {}, }]; - catalog.replaceItems(items); - catalog.activeEntity = catalog.items[0]; - expect(catalog.activeEntity.status.phase).toEqual("disconnected"); + entityRegistry.updateItems(items); + entityRegistry.activeEntity = entityRegistry.items.get()[0]; + expect(entityRegistry.activeEntity.status.phase).toEqual("disconnected"); items[0].status.phase = "connected"; - catalog.replaceItems(items); - expect(catalog.activeEntity.status.phase).toEqual("connected"); + entityRegistry.updateItems(items); + expect(entityRegistry.activeEntity.status.phase).toEqual("connected"); }); it("removes deleted items", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", @@ -208,17 +209,16 @@ describe("CatalogEntityRegistry", () => { }, ]; - catalog.replaceItems(items); + entityRegistry.updateItems(items); items.splice(0, 1); - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); - expect(catalog.items[0].metadata.uid).toEqual("456"); + entityRegistry.updateItems(items); + expect(entityRegistry.items.get().length).toEqual(1); + expect(entityRegistry.items.get()[0].metadata.uid).toEqual("456"); }); }); describe("items", () => { it("does not return items without matching category", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", @@ -250,13 +250,12 @@ describe("CatalogEntityRegistry", () => { }, ]; - catalog.replaceItems(items); - expect(catalog.items.length).toBe(1); + entityRegistry.updateItems(items); + expect(entityRegistry.items.get().length).toBe(1); }); }); it("does return items after matching category is added", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", @@ -274,29 +273,28 @@ describe("CatalogEntityRegistry", () => { }, ]; - catalog.replaceItems(items); + entityRegistry.updateItems(items); catalogCategoryRegistry.add(new FooBarCategory()); - expect(catalog.items.length).toBe(1); + expect(entityRegistry.items.get().length).toBe(1); }); it("does not return items that are filtered out", () => { const source = observable.array([entity, entity2, entitykc]); - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); - catalog.replaceItems(source); + entityRegistry.updateItems(source); - expect(catalog.items.length).toBe(3); - expect(catalog.filteredItems.length).toBe(3); + expect(entityRegistry.items.get().length).toBe(3); + expect(entityRegistry.filteredItems.length).toBe(3); - const d = catalog.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind); + const d = entityRegistry.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind); - expect(catalog.items.length).toBe(3); - expect(catalog.filteredItems.length).toBe(1); + expect(entityRegistry.items.get().length).toBe(3); + expect(entityRegistry.filteredItems.length).toBe(1); // Remove filter d(); - expect(catalog.items.length).toBe(3); - expect(catalog.filteredItems.length).toBe(3); + expect(entityRegistry.items.get().length).toBe(3); + expect(entityRegistry.filteredItems.length).toBe(3); }); }); diff --git a/src/renderer/api/catalog-category-registry.ts b/src/renderer/api/catalog-category-registry.ts index 6cdbd8eb71..9023c5a755 100644 --- a/src/renderer/api/catalog-category-registry.ts +++ b/src/renderer/api/catalog-category-registry.ts @@ -3,5 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { catalogCategoryRegistry } from "../../common/catalog"; +import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable"; +import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + export type { CategoryFilter } from "../../common/catalog"; + +export const catalogCategoryRegistry = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable); diff --git a/src/renderer/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts index 09148c79a3..c4dea91071 100644 --- a/src/renderer/api/catalog-entity.ts +++ b/src/renderer/api/catalog-entity.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { catalogEntityRunContext } from "./catalog-entity-registry"; export { CatalogCategory, CatalogEntity } from "../../common/catalog"; export type { CatalogEntityData, diff --git a/src/renderer/api/catalog/entity/active.injectable.ts b/src/renderer/api/catalog/entity/active.injectable.ts new file mode 100644 index 0000000000..1a0be2c457 --- /dev/null +++ b/src/renderer/api/catalog/entity/active.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import catalogEntityRegistryInjectable from "./registry.injectable"; + +const activeEntityInjectable = getInjectable({ + id: "active-entity", + instantiate: (di) => { + const registry = di.inject(catalogEntityRegistryInjectable); + + return computed(() => registry.activeEntity); + }, +}); + +export default activeEntityInjectable; diff --git a/src/renderer/api/catalog/entity/entities.injectable.ts b/src/renderer/api/catalog/entity/entities.injectable.ts new file mode 100644 index 0000000000..f14df295fa --- /dev/null +++ b/src/renderer/api/catalog/entity/entities.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "./registry.injectable"; + +const catalogEnitiesInjectable = getInjectable({ + id: "catalog-enities", + instantiate: (di) => di.inject(catalogEntityRegistryInjectable).items, +}); + +export default catalogEnitiesInjectable; diff --git a/src/renderer/api/catalog/entity/get-active-cluster-entity.injectable.ts b/src/renderer/api/catalog/entity/get-active-cluster-entity.injectable.ts new file mode 100644 index 0000000000..d5a2d1f3f8 --- /dev/null +++ b/src/renderer/api/catalog/entity/get-active-cluster-entity.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import clusterStoreInjectable from "../../../../common/cluster-store/cluster-store.injectable"; +import type { Cluster } from "../../../../common/cluster/cluster"; +import catalogEntityRegistryInjectable from "./registry.injectable"; + +export type GetActiveClusterEntity = () => Cluster | undefined; + +const getActiveClusterEntityInjectable = getInjectable({ + id: "get-active-cluster-entity", + instantiate: (di): GetActiveClusterEntity => { + const store = di.inject(clusterStoreInjectable); + const entityRegistry = di.inject(catalogEntityRegistryInjectable); + + return () => store.getById(entityRegistry.activeEntity?.getId()); + }, +}); + +export default getActiveClusterEntityInjectable; diff --git a/src/renderer/api/catalog/entity/legacy-globals.ts b/src/renderer/api/catalog/entity/legacy-globals.ts new file mode 100644 index 0000000000..70a30f92f8 --- /dev/null +++ b/src/renderer/api/catalog/entity/legacy-globals.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalFunctionForExtensionApi } from "../../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import getActiveClusterEntityInjectable from "./get-active-cluster-entity.injectable"; + +/** + * @deprecated use `di.inject(getActiveClusterEntityInjectable)` instead + */ +export const getActiveClusterEntity = asLegacyGlobalFunctionForExtensionApi(getActiveClusterEntityInjectable); diff --git a/src/renderer/api/catalog/entity/registry.injectable.ts b/src/renderer/api/catalog/entity/registry.injectable.ts new file mode 100644 index 0000000000..03d4edf52e --- /dev/null +++ b/src/renderer/api/catalog/entity/registry.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable"; +import navigateInjectable from "../../../navigation/navigate.injectable"; +import { CatalogEntityRegistry } from "./registry"; + +const catalogEntityRegistryInjectable = getInjectable({ + id: "catalog-entity-registry", + instantiate: (di) => new CatalogEntityRegistry({ + categoryRegistry: di.inject(catalogCategoryRegistryInjectable), + navigate: di.inject(navigateInjectable), + }), +}); + +export default catalogEntityRegistryInjectable; diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog/entity/registry.ts similarity index 73% rename from src/renderer/api/catalog-entity-registry.ts rename to src/renderer/api/catalog/entity/registry.ts index 1cc3f24a0d..1f53cf3d94 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog/entity/registry.ts @@ -4,56 +4,57 @@ */ import { computed, observable, makeObservable, action } from "mobx"; -import { ipcRendererOn } from "../../common/ipc"; -import type { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; -import { catalogCategoryRegistry } from "../../common/catalog"; -import "../../common/catalog-entities"; -import type { Cluster } from "../../common/cluster/cluster"; -import { ClusterStore } from "../../common/cluster-store/cluster-store"; -import { iter } from "../utils"; -import type { Disposer } from "../utils"; +import { ipcRendererOn } from "../../../../common/ipc"; +import type { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogCategoryRegistry, CatalogEntityKindData } from "../../../../common/catalog"; +import "../../../../common/catalog-entities"; +import { iter } from "../../../utils"; +import type { Disposer } from "../../../utils"; import { once } from "lodash"; -import logger from "../../common/logger"; -import { CatalogRunEvent } from "../../common/catalog/catalog-run-event"; +import logger from "../../../../common/logger"; +import { CatalogRunEvent } from "../../../../common/catalog/catalog-run-event"; import { ipcRenderer } from "electron"; -import { catalogInitChannel, catalogItemsChannel, catalogEntityRunListener } from "../../common/ipc/catalog"; -import { navigate } from "../navigation"; +import { catalogInitChannel, catalogItemsChannel, catalogEntityRunListener } from "../../../../common/ipc/catalog"; import { isMainFrame } from "process"; +import type { Navigate } from "../../../navigation/navigate.injectable"; export type EntityFilter = (entity: CatalogEntity) => any; export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise; -export const catalogEntityRunContext = { - navigate: (url: string) => navigate(url), - setCommandPaletteContext: (entity?: CatalogEntity) => { - catalogEntityRegistry.activeEntity = entity; - }, -}; +interface Dependencies { + navigate: Navigate; + readonly categoryRegistry: CatalogCategoryRegistry; +} export class CatalogEntityRegistry { - @observable protected activeEntityId: string | undefined = undefined; - protected _entities = observable.map([], { deep: true }); - protected filters = observable.set([], { + protected readonly activeEntityId = observable.box(undefined); + protected readonly _entities = observable.map([], { deep: true }); + protected readonly filters = observable.set([], { deep: false, }); - protected onBeforeRunHooks = observable.set([], { + protected readonly onBeforeRunHooks = observable.set([], { deep: false, }); /** * Buffer for keeping entities that don't yet have CatalogCategory synced */ - protected rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = []; + protected readonly rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = []; - constructor(private categoryRegistry: CatalogCategoryRegistry) { + constructor(protected readonly dependencies: Dependencies) { makeObservable(this); } protected getActiveEntityById() { - return this._entities.get(this.activeEntityId) || null; + const activeEntityId = this.activeEntityId.get(); + + if (!activeEntityId) { + return undefined; + } + + return this._entities.get(activeEntityId); } - get activeEntity(): CatalogEntity | null { + get activeEntity(): CatalogEntity | undefined { const entity = this.getActiveEntityById(); // If the entity was not found but there are rawEntities to be processed, @@ -68,15 +69,15 @@ export class CatalogEntityRegistry { return entity; } - set activeEntity(raw: CatalogEntity | string | null) { + set activeEntity(raw: CatalogEntity | string | undefined) { if (raw) { const id = typeof raw === "string" ? raw : raw.getId(); - this.activeEntityId = id; + this.activeEntityId.set(id); } else { - this.activeEntityId = undefined; + this.activeEntityId.set(undefined); } } @@ -119,7 +120,7 @@ export class CatalogEntityRegistry { const existing = this._entities.get(item.metadata.uid); if (!existing) { - const entity = this.categoryRegistry.getEntityForData(item); + const entity = this.dependencies.categoryRegistry.getEntityForData(item); if (entity) { this._entities.set(entity.getId(), entity); @@ -143,25 +144,25 @@ export class CatalogEntityRegistry { } } - @computed get items() { + readonly items = computed(() => { this.processRawEntities(); return Array.from(this._entities.values()); - } + }); @computed get filteredItems() { return Array.from( iter.reduce( this.filters, iter.filter, - this.items.values(), + this.items.get().values(), ), ); } @computed get entities(): Map { return new Map( - this.items.map(entity => [entity.getId(), entity]), + this.items.get().map(entity => [entity.getId(), entity]), ); } @@ -171,13 +172,13 @@ export class CatalogEntityRegistry { ); } - getById(id: string) { - return this.entities.get(id) as T; + getById(id: string) { + return this.entities.get(id); } getItemsForApiKind(apiVersion: string, kind: string, { filtered = false } = {}): T[] { const byApiKind = (item: CatalogEntity) => item.apiVersion === apiVersion && item.kind === kind; - const entities = filtered ? this.filteredItems : this.items; + const entities = filtered ? this.filteredItems : this.items.get(); return entities.filter(byApiKind) as T[]; } @@ -185,7 +186,7 @@ export class CatalogEntityRegistry { getItemsForCategory(category: CatalogCategory, { filtered = false } = {}): T[] { const supportedVersions = new Set(category.spec.versions.map((v) => `${category.spec.group}/${v.name}`)); const byApiVersionKind = (item: CatalogEntity) => supportedVersions.has(item.apiVersion) && item.kind === category.spec.names.kind; - const entities = filtered ? this.filteredItems : this.items; + const entities = filtered ? this.filteredItems : this.items.get(); return entities.filter(byApiVersionKind) as T[]; } @@ -245,7 +246,12 @@ export class CatalogEntityRegistry { this.onBeforeRun(entity) .then(doOnRun => { if (doOnRun) { - return entity.onRun?.(catalogEntityRunContext); + return entity.onRun?.({ + navigate: this.dependencies.navigate, + setCommandPaletteContext: (entity) => { + this.activeEntity = entity; + }, + }); } else { logger.debug(`onBeforeRun for ${entity.getId()} returned false`); } @@ -253,9 +259,3 @@ export class CatalogEntityRegistry { .catch(error => logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error)); } } - -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - -export function getActiveClusterEntity(): Cluster | undefined { - return ClusterStore.getInstance().getById(catalogEntityRegistry.activeEntity?.getId()); -} diff --git a/src/renderer/api/create-terminal-api.injectable.ts b/src/renderer/api/create-terminal-api.injectable.ts new file mode 100644 index 0000000000..f9f151dace --- /dev/null +++ b/src/renderer/api/create-terminal-api.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import hostedClusterIdInjectable from "../../common/cluster-store/hosted-cluster-id.injectable"; +import type { TerminalApiQuery } from "./terminal-api"; +import { TerminalApi } from "./terminal-api"; + +export type CreateTerminalApi = (query: TerminalApiQuery) => TerminalApi; + +const createTerminalApiInjectable = getInjectable({ + id: "create-terminal-api", + instantiate: (di): CreateTerminalApi => { + const hostedClusterId = di.inject(hostedClusterIdInjectable); + + return (query) => { + assert(hostedClusterId, "Can only create terminal APIs within a cluster frame"); + + return new TerminalApi({ + hostedClusterId, + }, query); + }; + }, +}); + +export default createTerminalApiInjectable; diff --git a/src/renderer/api/helpers/general-active-sync.ts b/src/renderer/api/helpers/general-active-sync.ts deleted file mode 100644 index ef548d7a9a..0000000000 --- a/src/renderer/api/helpers/general-active-sync.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { when } from "mobx"; -import { catalogCategoryRegistry } from "../../../common/catalog"; -import { catalogEntityRegistry } from "../catalog-entity-registry"; -import { isActiveRoute } from "../../navigation"; -import type { GeneralEntity } from "../../../common/catalog-entities"; - -export async function setEntityOnRouteMatch() { - await when(() => catalogEntityRegistry.entities.size > 0); - - const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); - const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); - - if (activeEntity) { - catalogEntityRegistry.activeEntity = activeEntity; - } -} diff --git a/src/renderer/api/helpers/watch-for-general-entity-navigation.injectable.ts b/src/renderer/api/helpers/watch-for-general-entity-navigation.injectable.ts new file mode 100644 index 0000000000..14ae9f9f98 --- /dev/null +++ b/src/renderer/api/helpers/watch-for-general-entity-navigation.injectable.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { reaction, when } from "mobx"; +import generalCategoryInjectable from "../../../common/catalog/categories/general.injectable"; +import isActiveRouteInjectable from "../../navigation/is-route-active.injectable"; +import observableHistoryInjectable from "../../navigation/observable-history.injectable"; +import type { Disposer } from "../../utils"; +import { disposer } from "../../utils"; +import catalogEntityRegistryInjectable from "../catalog/entity/registry.injectable"; + +export type WatchForGeneralEntityNavigation = () => Disposer; + +const watchForGeneralEntityNavigationInjectable = getInjectable({ + id: "watch-for-general-entity-navigation", + instantiate: (di): WatchForGeneralEntityNavigation => { + const observableHistory = di.inject(observableHistoryInjectable); + const isActiveRoute = di.inject(isActiveRouteInjectable); + const entityRegistry = di.inject(catalogEntityRegistryInjectable); + const generalCategory = di.inject(generalCategoryInjectable); + + return () => { + const dispose = disposer(); + + dispose.push(when( + () => entityRegistry.entities.size > 0, + () => { + dispose.push(reaction( + () => observableHistory.location, + () => { + const entities = entityRegistry.getItemsForCategory(generalCategory); + const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); + + if (activeEntity) { + entityRegistry.activeEntity = activeEntity; + } + }, + { + fireImmediately: true, + }, + )); + }, + )); + + return dispose; + }; + }, +}); + +export default watchForGeneralEntityNavigationInjectable; diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 4b30b7e459..379d1e292b 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -3,22 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { JsonApiErrorParsed } from "../../common/k8s-api/json-api"; -import type { Response } from "node-fetch"; -import { Notifications } from "../components/notifications"; -import { apiBase, apiKube } from "../../common/k8s-api"; export { apiBase, apiKube } from "../../common/k8s-api"; - - -// Common handler for HTTP api errors -export function onApiError(error: JsonApiErrorParsed, res: Response) { - switch (res.status) { - case 403: - error.isUsedForNotification = true; - Notifications.error(error); - break; - } -} - -if (apiBase) apiBase.onError.addListener(onApiError); -if (apiKube) apiKube.onError.addListener(onApiError); diff --git a/src/renderer/api/on-api-error.ts b/src/renderer/api/on-api-error.ts new file mode 100644 index 0000000000..c3cb2bb7f7 --- /dev/null +++ b/src/renderer/api/on-api-error.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Response } from "node-fetch"; +import type { JsonApiErrorParsed } from "../../common/k8s-api/json-api"; +import { Notifications } from "../components/notifications"; + +export function onApiError(error: JsonApiErrorParsed, res: Response) { + switch (res.status) { + case 403: + error.isUsedForNotification = true; + Notifications.error(error); + break; + } +} diff --git a/src/renderer/api/setup-on-api-errors.injectable.ts b/src/renderer/api/setup-on-api-errors.injectable.ts new file mode 100644 index 0000000000..859b333586 --- /dev/null +++ b/src/renderer/api/setup-on-api-errors.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { apiBase } from "../../common/k8s-api"; +import { onApiError } from "./on-api-error"; +import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token"; + +const setupOnApiErrorListenersInjectable = getInjectable({ + id: "setup-on-api-error-listeners", + + instantiate: () => ({ + run: () => { + apiBase?.onError.addListener(onApiError); + }, + }), + + injectionToken: beforeFrameStartsInjectionToken, + causesSideEffects: true, +}); + +export default setupOnApiErrorListenersInjectable; diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 1e8c86a77c..75d907616c 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getHostedClusterId } from "../utils"; import type { WebSocketEvents } from "./websocket-api"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; @@ -11,31 +10,8 @@ import url from "url"; import { makeObservable, observable } from "mobx"; import { ipcRenderer } from "electron"; import logger from "../../common/logger"; -import { deserialize, serialize } from "v8"; import { once } from "lodash"; - -export enum TerminalChannels { - STDIN = "stdin", - STDOUT = "stdout", - CONNECTED = "connected", - RESIZE = "resize", -} - -export type TerminalMessage = { - type: TerminalChannels.STDIN; - data: string; -} | { - type: TerminalChannels.STDOUT; - data: string; -} | { - type: TerminalChannels.CONNECTED; -} | { - type: TerminalChannels.RESIZE; - data: { - width: number; - height: number; - }; -}; +import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; enum TerminalColor { RED = "\u001b[31m", @@ -49,23 +25,27 @@ enum TerminalColor { NO_COLOR = "\u001b[0m", } -export type TerminalApiQuery = Record & { +export interface TerminalApiQuery extends Record { id: string; node?: string; type?: string; -}; +} export interface TerminalEvents extends WebSocketEvents { ready: () => void; connected: () => void; } +export interface TerminalApiDependencies { + readonly hostedClusterId: string; +} + export class TerminalApi extends WebSocketApi { - protected size: { width: number; height: number }; + protected size?: { width: number; height: number }; @observable public isReady = false; - constructor(protected query: TerminalApiQuery) { + constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) { super({ flushOnOpen: false, pingInterval: 30, @@ -86,7 +66,7 @@ export class TerminalApi extends WebSocketApi { this.emitStatus("Connecting ..."); } - const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", this.dependencies.hostedClusterId, this.query.id); if (!(authTokenArray instanceof Uint8Array)) { throw new TypeError("ShellApi token is not a Uint8Array"); @@ -114,11 +94,15 @@ export class TerminalApi extends WebSocketApi { // data is undefined if the event that was handled is "connected" if (data === undefined) { - /** - * Output the last line, the makes sure that the terminal isn't completely - * empty when the user refreshes. - */ - this.emit("data", window.localStorage.getItem(`${this.query.id}:last-data`)); + const lastData = window.localStorage.getItem(`${this.query.id}:last-data`); + + if (lastData) { + /** + * Output the last line, the makes sure that the terminal isn't completely + * empty when the user refreshes. + */ + this.emit("data", lastData); + } } }); @@ -126,11 +110,10 @@ export class TerminalApi extends WebSocketApi { this.prependListener("connected", onReady); super.connect(socketUrl); - this.socket.binaryType = "arraybuffer"; } sendMessage(message: TerminalMessage) { - return this.send(serialize(message)); + return this.send(JSON.stringify(message)); } sendTerminalSize(cols: number, rows: number) { @@ -145,9 +128,9 @@ export class TerminalApi extends WebSocketApi { } } - protected _onMessage({ data, ...evt }: MessageEvent): void { + protected _onMessage({ data, ...evt }: MessageEvent): void { try { - const message: TerminalMessage = deserialize(new Uint8Array(data)); + const message = JSON.parse(data) as TerminalMessage; switch (message.type) { case TerminalChannels.STDOUT: diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index 72ebc2d059..748fef5e7b 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -8,6 +8,8 @@ import EventEmitter from "events"; import type TypedEventEmitter from "typed-emitter"; import type { Arguments } from "typed-emitter"; import { isDevelopment } from "../../common/vars"; +import type { Defaulted } from "../utils"; +import { TerminalChannels, type TerminalMessage } from "../../common/terminal/channels"; interface WebsocketApiParams { /** @@ -29,9 +31,9 @@ interface WebsocketApiParams { /** * The message for pinging the websocket * - * @default "PING" + * @default "{type: \"ping\"}" */ - pingMessage?: string | ArrayBufferLike | Blob | ArrayBufferView; + pingMessage?: string; /** * If set to a number > 0, then the API will ping the socket on that interval. @@ -62,39 +64,44 @@ export interface WebSocketEvents { close: () => void; } -type Defaulted = Required> & Omit; - export class WebSocketApi extends (EventEmitter as { new(): TypedEventEmitter }) { - protected socket?: WebSocket | null; - protected pendingCommands: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = []; - protected reconnectTimer?: any; - protected pingTimer?: any; + protected socket: WebSocket | null = null; + protected pendingCommands: string[] = []; + protected reconnectTimer?: number; + protected pingTimer?: number; protected params: Defaulted; @observable readyState = WebSocketApiState.PENDING; - private static defaultParams = { + private static readonly defaultParams = { logging: isDevelopment, reconnectDelay: 10, flushOnOpen: true, - pingMessage: "PING", + pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage), }; constructor(params: WebsocketApiParams) { super(); makeObservable(this); - this.params = Object.assign({}, WebSocketApi.defaultParams, params); + this.params = { + ...WebSocketApi.defaultParams, + ...params, + }; const { pingInterval } = this.params; if (pingInterval) { - this.pingTimer = setInterval(() => this.ping(), pingInterval * 1000); + this.pingTimer = window.setInterval(() => this.ping(), pingInterval * 1000); } } - get isConnected() { + protected getIsConnected(): this is (WebSocketApi & { socket: WebSocket }) { return this.socket?.readyState === WebSocket.OPEN && this.isOnline; } + get isConnected() { + return this.getIsConnected(); + } + get isOnline() { return navigator.onLine; } @@ -143,8 +150,8 @@ export class WebSocketApi extends (EventEmitter } } - send(command: string | ArrayBufferLike | Blob | ArrayBufferView) { - if (this.isConnected) { + send(command: string) { + if (this.getIsConnected()) { this.socket.send(command); } else { this.pendingCommands.push(command); @@ -186,7 +193,7 @@ export class WebSocketApi extends (EventEmitter this.writeLog("will reconnect in", `${reconnectDelay}s`); - this.reconnectTimer = setTimeout(() => this.connect(url), reconnectDelay * 1000); + this.reconnectTimer = window.setTimeout(() => this.connect(url), reconnectDelay * 1000); this.readyState = WebSocketApiState.RECONNECTING; } } else { diff --git a/src/renderer/app-paths/app-paths.injectable.ts b/src/renderer/app-paths/app-paths.injectable.ts deleted file mode 100644 index e6c77c1840..0000000000 --- a/src/renderer/app-paths/app-paths.injectable.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import type { AppPaths } from "../../common/app-paths/app-path-injection-token"; -import { appPathsInjectionToken, appPathsIpcChannel } from "../../common/app-paths/app-path-injection-token"; -import getValueFromRegisteredChannelInjectable from "./get-value-from-registered-channel/get-value-from-registered-channel.injectable"; - -let syncAppPaths: AppPaths; - -const appPathsInjectable = getInjectable({ - id: "app-paths", - - setup: async (di) => { - const getValueFromRegisteredChannel = await di.inject( - getValueFromRegisteredChannelInjectable, - ); - - syncAppPaths = await getValueFromRegisteredChannel(appPathsIpcChannel); - }, - - instantiate: () => syncAppPaths, - - injectionToken: appPathsInjectionToken, -}); - -export default appPathsInjectable; diff --git a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts deleted file mode 100644 index 697783fbf5..0000000000 --- a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import ipcRendererInjectable from "./ipc-renderer/ipc-renderer.injectable"; -import { getValueFromRegisteredChannel } from "./get-value-from-registered-channel"; - -const getValueFromRegisteredChannelInjectable = getInjectable({ - id: "get-value-from-registered-channel", - - instantiate: (di) => - getValueFromRegisteredChannel({ ipcRenderer: di.inject(ipcRendererInjectable) }), -}); - -export default getValueFromRegisteredChannelInjectable; diff --git a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts deleted file mode 100644 index 0f3b44c569..0000000000 --- a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { IpcRenderer } from "electron"; -import type { Channel } from "../../../common/ipc-channel/channel"; - -interface Dependencies { - ipcRenderer: IpcRenderer; -} - -export const getValueFromRegisteredChannel = - ({ ipcRenderer }: Dependencies) => - , TInstance>( - channel: TChannel, - ): Promise => - ipcRenderer.invoke(channel.name); diff --git a/src/renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable.ts b/src/renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable.ts deleted file mode 100644 index 151d77f097..0000000000 --- a/src/renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import ipcRendererInjectable from "./ipc-renderer/ipc-renderer.injectable"; -import type { - IpcChannelListener, -} from "../../ipc-channel-listeners/ipc-channel-listener-injection-token"; - -const registerIpcChannelListenerInjectable = getInjectable({ - id: "register-ipc-channel-listener", - - instantiate: (di) => { - const ipc = di.inject(ipcRendererInjectable); - - return ({ channel, handle }: IpcChannelListener) => { - ipc.on(channel.name, (_, data) => { - handle(data); - }); - }; - }, -}); - -export default registerIpcChannelListenerInjectable; diff --git a/src/renderer/app-paths/setup-app-paths.injectable.ts b/src/renderer/app-paths/setup-app-paths.injectable.ts new file mode 100644 index 0000000000..14242347f4 --- /dev/null +++ b/src/renderer/app-paths/setup-app-paths.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable"; +import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token"; +import appPathsChannelInjectable from "../../common/app-paths/app-paths-channel.injectable"; +import { requestFromChannelInjectionToken } from "../../common/utils/channel/request-from-channel-injection-token"; + +const setupAppPathsInjectable = getInjectable({ + id: "setup-app-paths", + + instantiate: (di) => { + const requestFromChannel = di.inject(requestFromChannelInjectionToken); + const appPathsChannel = di.inject(appPathsChannelInjectable); + const appPathsState = di.inject(appPathsStateInjectable); + + return { + run: async () => { + const appPaths = await requestFromChannel( + appPathsChannel, + ); + + appPathsState.set(appPaths); + }, + }; + }, + + injectionToken: beforeFrameStartsInjectionToken, +}); + +export default setupAppPathsInjectable; diff --git a/src/renderer/application-update/application-update-status-listener.injectable.ts b/src/renderer/application-update/application-update-status-listener.injectable.ts new file mode 100644 index 0000000000..69ba23608c --- /dev/null +++ b/src/renderer/application-update/application-update-status-listener.injectable.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ApplicationUpdateStatusChannel, ApplicationUpdateStatusEventId } from "../../common/application-update/application-update-status-channel.injectable"; +import applicationUpdateStatusChannelInjectable from "../../common/application-update/application-update-status-channel.injectable"; +import showInfoNotificationInjectable from "../components/notifications/show-info-notification.injectable"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; + +const applicationUpdateStatusListenerInjectable = getInjectable({ + id: "application-update-status-listener", + + instantiate: (di): MessageChannelListener => { + const channel = di.inject(applicationUpdateStatusChannelInjectable); + const showInfoNotification = di.inject(showInfoNotificationInjectable); + + const eventHandlers: Record void }> = { + "checking-for-updates": { + handle: () => { + showInfoNotification("Checking for updates..."); + }, + }, + + "no-updates-available": { + handle: () => { + showInfoNotification("No new updates available"); + }, + }, + + "download-for-update-started": { + handle: (version) => { + showInfoNotification(`Download for version ${version} started...`); + }, + }, + + "download-for-update-failed": { + handle: () => { + showInfoNotification("Download of update failed"); + }, + }, + }; + + return { + channel, + + handler: ({ eventId, version }) => { + eventHandlers[eventId].handle(version); + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default applicationUpdateStatusListenerInjectable; diff --git a/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx b/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx new file mode 100644 index 0000000000..5e9adff4cc --- /dev/null +++ b/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx @@ -0,0 +1,107 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AskBooleanQuestionChannel } from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import askBooleanQuestionChannelInjectable from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import showInfoNotificationInjectable from "../components/notifications/show-info-notification.injectable"; +import { Button } from "../components/button"; +import React from "react"; +import { messageToChannelInjectionToken } from "../../common/utils/channel/message-to-channel-injection-token"; +import askBooleanAnswerChannelInjectable from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import notificationsStoreInjectable from "../components/notifications/notifications-store.injectable"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; + +const askBooleanQuestionChannelListenerInjectable = getInjectable({ + id: "ask-boolean-question-channel-listener", + + instantiate: (di): MessageChannelListener => { + const questionChannel = di.inject(askBooleanQuestionChannelInjectable); + const showInfoNotification = di.inject(showInfoNotificationInjectable); + const messageToChannel = di.inject(messageToChannelInjectionToken); + const answerChannel = di.inject(askBooleanAnswerChannelInjectable); + const notificationsStore = di.inject(notificationsStoreInjectable); + + const sendAnswerFor = (id: string) => (value: boolean) => { + messageToChannel(answerChannel, { id, value }); + }; + + const closeNotification = (notificationId: string) => { + notificationsStore.remove(notificationId); + }; + + const sendAnswerAndCloseNotificationFor = (sendAnswer: (value: boolean) => void, notificationId: string) => (value: boolean) => () => { + sendAnswer(value); + closeNotification(notificationId); + }; + + return { + channel: questionChannel, + + handler: ({ id: questionId, title, question }) => { + const notificationId = `ask-boolean-for-${questionId}`; + + const sendAnswer = sendAnswerFor(questionId); + const sendAnswerAndCloseNotification = sendAnswerAndCloseNotificationFor(sendAnswer, notificationId); + + showInfoNotification( + , + + { + id: notificationId, + timeout: 0, + onClose: () => sendAnswer(false), + }, + ); + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default askBooleanQuestionChannelListenerInjectable; + +const AskBoolean = ({ + id, + title, + message, + onNo, + onYes, +}: { + id: string; + title: string; + message: string; + onNo: () => void; + onYes: () => void; +}) => ( +
+ {title} +

{message}

+ +
+
+
+); diff --git a/src/renderer/before-frame-starts/before-frame-starts-injection-token.ts b/src/renderer/before-frame-starts/before-frame-starts-injection-token.ts new file mode 100644 index 0000000000..e494508329 --- /dev/null +++ b/src/renderer/before-frame-starts/before-frame-starts-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "../../common/runnable/run-many-for"; + +export const beforeFrameStartsInjectionToken = getInjectionToken({ + id: "before-frame-starts", +}); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 66a6600d5a..fabb17cb5a 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -36,13 +36,17 @@ import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-fram import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable"; import { Router } from "react-router"; import historyInjectable from "./navigation/history.injectable"; -import themeStoreInjectable from "./theme-store.injectable"; +import themeStoreInjectable from "./themes/store.injectable"; import navigateToAddClusterInjectable from "../common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable"; import addSyncEntriesInjectable from "./initializers/add-sync-entries.injectable"; -import hotbarStoreInjectable from "../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../common/hotbars/store.injectable"; import { bindEvents } from "./navigation/events"; -import deleteClusterDialogModelInjectable from "./components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable"; +import openDeleteClusterDialogInjectable from "./components/delete-cluster-dialog/open.injectable"; import { init } from "@sentry/electron/renderer"; +import kubernetesClusterCategoryInjectable from "../common/catalog/categories/kubernetes-cluster.injectable"; +import autoRegistrationInjectable from "../common/k8s-api/api-manager/auto-registration.injectable"; +import assert from "assert"; +import startFrameInjectable from "./start-frame/start-frame.injectable"; configurePackages(); // global packages registerCustomThemes(); // monaco editor themes @@ -63,7 +67,9 @@ export async function bootstrap(di: DiContainer) { initializeSentryReporting(init); } - await di.runSetups(); + const startFrame = di.inject(startFrameInjectable); + + await startFrame(); // TODO: Consolidate import time side-effect to setup time bindEvents(); @@ -71,6 +77,20 @@ export async function bootstrap(di: DiContainer) { const rootElem = document.getElementById("app"); const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`; + assert(rootElem, "#app MUST exist"); + + + /** + * This is injected here to initialize it for the side effect. + * + * The side effect CANNOT be within `apiManagerInjectable` itself since that causes circular + * dependencies with the current need for legacy di use. + * + * This also MUST be done before anything else so that it can start listening for the events for + * auto initialization. + */ + di.inject(autoRegistrationInjectable); + // TODO: Remove temporal dependencies to make timing of initialization not important di.inject(userStoreInjectable); @@ -89,19 +109,17 @@ export async function bootstrap(di: DiContainer) { logger.info(`${logPrefix} initializing CatalogEntityDetailRegistry`); initializers.initCatalogEntityDetailRegistry(); - const navigateToAddCluster = di.inject(navigateToAddClusterInjectable); - const addSyncEntries = di.inject(addSyncEntriesInjectable); - logger.info(`${logPrefix} initializing CatalogCategoryRegistryEntries`); initializers.initCatalogCategoryRegistryEntries({ - navigateToAddCluster, - addSyncEntries, + navigateToAddCluster: di.inject(navigateToAddClusterInjectable), + addSyncEntries: di.inject(addSyncEntriesInjectable), + kubernetesClusterCategory: di.inject(kubernetesClusterCategoryInjectable), }); logger.info(`${logPrefix} initializing Catalog`); initializers.initCatalog({ openCommandDialog: di.inject(commandOverlayInjectable).open, - deleteClusterDialogModel: di.inject(deleteClusterDialogModelInjectable), + openDeleteClusterDialog: di.inject(openDeleteClusterDialogInjectable), }); const extensionLoader = di.inject(extensionLoaderInjectable); @@ -121,7 +139,7 @@ export async function bootstrap(di: DiContainer) { await clusterStore.loadInitialOnRenderer(); // HotbarStore depends on: ClusterStore - di.inject(hotbarStoreInjectable); + di.inject(hotbarStoreInjectable).load(); // ThemeStore depends on: UserStore // TODO: Remove temporal dependencies @@ -144,7 +162,6 @@ export async function bootstrap(di: DiContainer) { // TODO: Introduce proper architectural boundaries between root and cluster iframes if (process.isMainFrame) { initializeApp = di.inject(initRootFrameInjectable); - App = (await import("./frames/root-frame/root-frame")).RootFrame; } else { initializeApp = di.inject(initClusterFrameInjectable); @@ -160,9 +177,11 @@ export async function bootstrap(di: DiContainer) { }); } + const history = di.inject(historyInjectable); + render( - + {DefaultProps(App)} , diff --git a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts index ad71a3dd17..08a921a87e 100644 --- a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts +++ b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts @@ -4,8 +4,9 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { ClusterFrameContext } from "./cluster-frame-context"; -import namespaceStoreInjectable from "../components/+namespaces/namespace-store/namespace-store.injectable"; +import namespaceStoreInjectable from "../components/+namespaces/store.injectable"; import hostedClusterInjectable from "../../common/cluster-store/hosted-cluster.injectable"; +import assert from "assert"; const clusterFrameContextInjectable = getInjectable({ id: "cluster-frame-context", @@ -13,13 +14,11 @@ const clusterFrameContextInjectable = getInjectable({ instantiate: (di) => { const cluster = di.inject(hostedClusterInjectable); - return new ClusterFrameContext( - cluster, + assert(cluster, "This can only be injected within a cluster frame"); - { - namespaceStore: di.inject(namespaceStoreInjectable), - }, - ); + return new ClusterFrameContext(cluster, { + namespaceStore: di.inject(namespaceStoreInjectable), + }); }, }); diff --git a/src/renderer/cluster-frame-context/cluster-frame-context.ts b/src/renderer/cluster-frame-context/cluster-frame-context.ts index 68ae6fa61d..1f1625e9e1 100755 --- a/src/renderer/cluster-frame-context/cluster-frame-context.ts +++ b/src/renderer/cluster-frame-context/cluster-frame-context.ts @@ -4,7 +4,7 @@ */ import type { Cluster } from "../../common/cluster/cluster"; -import type { NamespaceStore } from "../components/+namespaces/namespace-store/namespace.store"; +import type { NamespaceStore } from "../components/+namespaces/store"; import type { ClusterContext } from "../../common/k8s-api/cluster-context"; import { computed, makeObservable } from "mobx"; diff --git a/src/renderer/components/+add-cluster/add-cluster.module.scss b/src/renderer/components/+add-cluster/add-cluster.module.scss index 7418b4481d..0a43b2b805 100644 --- a/src/renderer/components/+add-cluster/add-cluster.module.scss +++ b/src/renderer/components/+add-cluster/add-cluster.module.scss @@ -3,6 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +@import "../../components/mixins.scss"; + .AddClusters { --flex-gap: calc(var(--unit) * 2); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index ce53440966..069846c0ac 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -16,7 +16,7 @@ import * as uuid from "uuid"; import { appEventBus } from "../../../common/app-event-bus/event-bus"; import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; import { docsUrl } from "../../../common/vars"; -import { iter } from "../../utils"; +import { isDefined, iter } from "../../utils"; import { Button } from "../button"; import { Notifications } from "../notifications"; import { SettingLayout } from "../layout/setting-layout"; @@ -66,7 +66,7 @@ class NonInjectedAddCluster extends React.Component { return [ ...this.errors, ...iter.map(this.kubeContexts.values(), ({ error }) => error), - ].filter(Boolean); + ].filter(isDefined); } readonly refreshContexts = debounce(action(() => { @@ -106,8 +106,18 @@ class NonInjectedAddCluster extends React.Component {

Add Clusters from Kubeconfig

- Clusters added here are not merged into the ~/.kube/config file.{" "} - Read more about adding clusters. + {"Clusters added here are "} + not + {" merged into the "} + ~/.kube/config + {" file. "} + + Read more about adding clusters. +

{ + describe("getTotalCount", () => { + let store: CatalogEntityStore; + let testCategoryOne: TestCategoryOne; + let testCategoryTwo: TestCategoryTwo; + + beforeEach(() => { + const entityItems = [ + new TestEntityOne({ + metadata: { + labels: {}, + name: "my-test-one", + uid: "1", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityOne({ + metadata: { + labels: {}, + name: "my-test-two", + uid: "2", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityTwo({ + metadata: { + labels: {}, + name: "my-test-three", + uid: "3", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityTwo({ + metadata: { + labels: {}, + name: "my-test-four", + uid: "4", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityTwo({ + metadata: { + labels: {}, + name: "my-test-five", + uid: "5", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + ]; + + testCategoryOne = new TestCategoryOne(); + testCategoryTwo = new TestCategoryTwo(); + store = catalogEntityStore({ + catalogRegistry: { + items: [ + testCategoryOne, + testCategoryTwo, + ], + }, + entityRegistry: { + onRun: noop, + filteredItems: entityItems, + getItemsForCategory: (category: CatalogCategory): T[] => { + return entityItems.filter(item => category.spec.versions.some(version => item instanceof version.entityClass)) as T[]; + }, + }, + }); + }); + + it("given no active category, returns count of all kinds", () => { + expect(store.getTotalCount()).toBe(5); + }); + + it("given active category is TestCategoryOne, only returns count for those declared kinds", () => { + store.activeCategory.set(testCategoryOne); + expect(store.getTotalCount()).toBe(2); + }); + + it("given active category is TestCategoryTwo, only returns count for those declared kinds", () => { + store.activeCategory.set(testCategoryTwo); + expect(store.getTotalCount()).toBe(3); + }); + }); +}); diff --git a/src/renderer/components/+catalog/__tests__/custom-columns.test.ts b/src/renderer/components/+catalog/__tests__/custom-columns.test.ts index 39dd880424..83b564566b 100644 --- a/src/renderer/components/+catalog/__tests__/custom-columns.test.ts +++ b/src/renderer/components/+catalog/__tests__/custom-columns.test.ts @@ -11,16 +11,16 @@ import rendererExtensionsInjectable from "../../../../extensions/renderer-extens import { CatalogCategory } from "../../../api/catalog-entity"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns"; -import type { CategoryColumns, GetCategoryColumnsParams } from "../get-category-columns.injectable"; -import getCategoryColumnsInjectable from "../get-category-columns.injectable"; -import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable"; +import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable"; +import getCategoryColumnsInjectable from "../columns/get.injectable"; +import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; class TestCategory extends CatalogCategory { apiVersion = "catalog.k8slens.dev/v1alpha1"; kind = "CatalogCategory"; - metadata: { - name: "Test"; - icon: "question_mark"; + metadata = { + name: "Test", + icon: "question_mark", }; spec: CatalogCategorySpec = { group: "foo.bar.bat", @@ -32,7 +32,10 @@ class TestCategory extends CatalogCategory { constructor(columns?: CategoryColumnRegistration[]) { super(); - this.spec.displayColumns = columns; + this.spec = { + displayColumns: columns, + ...this.spec, + }; } } @@ -54,19 +57,19 @@ describe("Custom Category Columns", () => { }); it("should contain a kind column if activeCategory is falsy", () => { - expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem.title === "Kind")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy(); }); it("should not contain a kind column if activeCategory is truthy", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Kind")).toBeFalsy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeFalsy(); }); it("should include the default columns if the provided category doesn't provide any", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Source")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Source")).toBeTruthy(); }); it("should not include the default columns if the provided category provides any", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem.title === "Source")).toBeFalsy(); + expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy(); }); it("should include the displayColumns from the provided category", () => { @@ -80,7 +83,7 @@ describe("Custom Category Columns", () => { }, ]; - expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem.title === "Foo")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy(); }); }); @@ -117,11 +120,11 @@ describe("Custom Category Columns", () => { }); it("should include columns from extensions that match", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High")).toBeTruthy(); }); it("should not include columns from extensions that don't match", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High2")).toBeFalsy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy(); }); }); }); diff --git a/src/renderer/components/+catalog/__tests__/custom-views.test.ts b/src/renderer/components/+catalog/__tests__/custom-views.test.ts index 84a0b6f3a4..8bd996505e 100644 --- a/src/renderer/components/+catalog/__tests__/custom-views.test.ts +++ b/src/renderer/components/+catalog/__tests__/custom-views.test.ts @@ -20,8 +20,8 @@ describe("Custom Category Views", () => { }); it("should order items correctly over all extensions", () => { - const component1 = (): React.ReactElement => null; - const component2 = (): React.ReactElement => null; + const component1 = (): React.ReactNode => null; + const component2 = (): React.ReactNode => null; di.override(rendererExtensionsInjectable, () => computed(() => [ { @@ -51,15 +51,15 @@ describe("Custom Category Views", () => { ] as LensRendererExtension[])); const customCategoryViews = di.inject(customCategoryViewsInjectable); - const { after } = customCategoryViews.get().get("foo").get("bar"); + const { after = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {}; expect(after[0].View).toBe(component2); expect(after[1].View).toBe(component1); }); it("should put put priority < 50 items in before", () => { - const component1 = (): React.ReactElement => null; - const component2 = (): React.ReactElement => null; + const component1 = (): React.ReactNode => null; + const component2 = (): React.ReactNode => null; di.override(rendererExtensionsInjectable, () => computed(() => [ { @@ -89,7 +89,7 @@ describe("Custom Category Views", () => { ] as LensRendererExtension[])); const customCategoryViews = di.inject(customCategoryViewsInjectable); - const { before } = customCategoryViews.get().get("foo").get("bar"); + const { before = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {}; expect(before[0].View).toBe(component1); }); diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx index c56259837c..227dd811b5 100644 --- a/src/renderer/components/+catalog/catalog-add-button.tsx +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -114,18 +114,20 @@ export class CatalogAddButton extends React.Component { onClick={this.onButtonClick} > {this.items.map((menuItem, index) => { - return } - tooltipTitle={menuItem.title} - onClick={(evt) => { - evt.stopPropagation(); - menuItem.onClick(); - }} - TooltipClasses={{ - popper: "catalogSpeedDialPopper", - }} - />; + return ( + } + tooltipTitle={menuItem.title} + onClick={(evt) => { + evt.stopPropagation(); + menuItem.onClick(); + }} + TooltipClasses={{ + popper: "catalogSpeedDialPopper", + }} + /> + ); })} ); diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index 569e6b60c1..fb70b06062 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -14,16 +14,22 @@ import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { isDevelopment } from "../../../common/vars"; import { cssNames } from "../../utils"; import { Avatar } from "../avatar"; -import { getLabelBadges } from "./helpers"; +import type { GetLabelBadges } from "./get-label-badges.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import getLabelBadgesInjectable from "./get-label-badges.injectable"; -export interface CatalogEntityDetailsProps { - entity: T; +export interface CatalogEntityDetailsProps { + entity: Entity; hideDetails(): void; onRun: () => void; } +interface Dependencies { + getLabelBadges: GetLabelBadges; +} + @observer -export class CatalogEntityDetails extends Component> { +class NonInjectedCatalogEntityDetails extends Component & Dependencies> { categoryIcon(category: CatalogCategory) { if (Icon.isSvg(category.metadata.icon)) { return ; @@ -32,11 +38,11 @@ export class CatalogEntityDetails extends Component ); - const showDefaultDetails = detailItems.find((item) => item.priority > 999) === undefined; + const showDefaultDetails = detailItems.find((item) => item.priority ?? 50 > 999) === undefined; return ( <> @@ -109,3 +115,10 @@ export class CatalogEntityDetails extends Component>(NonInjectedCatalogEntityDetails, { + getProps: (di, props) => ({ + ...props, + getLabelBadges: di.inject(getLabelBadgesInjectable), + }), +}) as (props: CatalogEntityDetailsProps) => React.ReactElement; diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index 9fd5867965..1cf39c4809 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -4,45 +4,42 @@ */ import React from "react"; -import { cssNames } from "../../utils"; +import { cssNames, hasDefiniteField } from "../../utils"; import type { MenuActionsProps } from "../menu/menu-actions"; import { MenuActions } from "../menu/menu-actions"; -import type { CatalogEntity, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity"; import { observer } from "mobx-react"; -import { makeObservable, observable } from "mobx"; +import { observable } from "mobx"; import { MenuItem } from "../menu"; import { Icon } from "../icon"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; +import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable"; +import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; import type { Navigate } from "../../navigation/navigate.injectable"; import navigateInjectable from "../../navigation/navigate.injectable"; import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable"; -export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { - entity: T; +export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { + entity: Entity; } interface Dependencies { normalizeMenuItem: NormalizeCatalogEntityContextMenu; navigate: Navigate; + visitEntityContextMenu: VisitEntityContextMenu; } @observer class NonInjectedCatalogEntityDrawerMenu extends React.Component & Dependencies> { - @observable private contextMenu: CatalogEntityContextMenuContext; - - constructor(props: CatalogEntityDrawerMenuProps & Dependencies) { - super(props); - makeObservable(this); - } + private readonly menuItems = observable.array(); componentDidMount() { - this.contextMenu = { - menuItems: [], + this.props.visitEntityContextMenu(this.props.entity, { + menuItems: this.menuItems, navigate: this.props.navigate, - }; - this.props.entity?.onContextMenuOpen(this.contextMenu); + }); } getMenuItems(entity: T): React.ReactChild[] { @@ -50,9 +47,9 @@ class NonInjectedCatalogEntityDrawerMenu extends React. return []; } - const items = this.contextMenu.menuItems - .filter(menuItem => menuItem.icon) + const items = this.menuItems .map(this.props.normalizeMenuItem) + .filter(hasDefiniteField("icon")) .map(menuItem => ( extends React. } - removeContent={} + addContent={( + + )} + removeContent={( + + )} />, ); @@ -78,7 +89,7 @@ class NonInjectedCatalogEntityDrawerMenu extends React. render() { const { className, entity, ...menuProps } = this.props; - if (!this.contextMenu || !entity.isEnabled()) { + if (!this.menuItems.length || !entity.isEnabled()) { return null; } @@ -97,7 +108,8 @@ class NonInjectedCatalogEntityDrawerMenu extends React. export const CatalogEntityDrawerMenu = withInjectables>(NonInjectedCatalogEntityDrawerMenu, { getProps: (di, props) => ({ ...props, + visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable), normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable), navigate: di.inject(navigateInjectable), }), -}) as (props: CatalogEntityDrawerMenuProps) => React.ReactElement; +}) as (props: CatalogEntityDrawerMenuProps) => JSX.Element; diff --git a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts index 8509366307..6e3cb979e4 100644 --- a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts @@ -3,14 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { CatalogEntityStore } from "./catalog-entity.store"; -import catalogEntityRegistryInjectable from "../../../api/catalog-entity-registry/catalog-entity-registry.injectable"; +import { catalogEntityStore } from "./catalog-entity.store"; +import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable"; +import catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable"; const catalogEntityStoreInjectable = getInjectable({ id: "catalog-entity-store", - instantiate: (di) => new CatalogEntityStore({ - registry: di.inject(catalogEntityRegistryInjectable), + instantiate: (di) => catalogEntityStore({ + entityRegistry: di.inject(catalogEntityRegistryInjectable), + catalogRegistry: di.inject(catalogCategoryRegistryInjectable), }), }); diff --git a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx index 732f5e822f..3209428cba 100644 --- a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx @@ -3,61 +3,84 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { computed, makeObservable, observable, reaction } from "mobx"; -import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; +import type { IComputedValue, IObservableValue } from "mobx"; +import { computed, observable, reaction } from "mobx"; +import type { CatalogEntityRegistry } from "../../../api/catalog/entity/registry"; import type { CatalogEntity } from "../../../api/catalog-entity"; -import { ItemStore } from "../../../../common/item.store"; -import type { CatalogCategory } from "../../../../common/catalog"; -import { catalogCategoryRegistry } from "../../../../common/catalog"; -import { autoBind, disposer } from "../../../../common/utils"; +import type { CatalogCategory, CatalogCategoryRegistry } from "../../../../common/catalog"; +import type { Disposer } from "../../../../common/utils"; +import { disposer } from "../../../../common/utils"; +import type { ItemListStore } from "../../item-object-list"; + +type EntityRegistry = Pick; +type CatalogRegistry = Pick; interface Dependencies { - registry: CatalogEntityRegistry; + entityRegistry: EntityRegistry; + catalogRegistry: CatalogRegistry; } -export class CatalogEntityStore extends ItemStore { - constructor(private dependencies: Dependencies) { - super(); - makeObservable(this); - autoBind(this); - } +export type CatalogEntityStore = ItemListStore & { + readonly entities: IComputedValue; + readonly activeCategory: IObservableValue; + readonly selectedItemId: IObservableValue; + readonly selectedItem: IComputedValue; + watch(): Disposer; + onRun(entity: CatalogEntity): void; +}; - @observable activeCategory?: CatalogCategory; - @observable selectedItemId?: string; +export function catalogEntityStore({ + entityRegistry, + catalogRegistry, +}: Dependencies): CatalogEntityStore { + const activeCategory = observable.box(undefined); + const selectedItemId = observable.box(undefined); + const entities = computed(() => { + const category = activeCategory.get(); - @computed get entities() { - if (!this.activeCategory) { - return this.dependencies.registry.filteredItems; + return category + ? entityRegistry.getItemsForCategory(category, { filtered: true }) + : entityRegistry.filteredItems; + }); + const selectedItem = computed(() => { + const id = selectedItemId.get(); + + if (!id) { + return undefined; } - return this.dependencies.registry.getItemsForCategory(this.activeCategory, { filtered: true }); - } + return entities.get().find(entity => entity.getId() === id); + }); + const loadAll = () => { + const category = activeCategory.get(); - @computed get selectedItem() { - return this.entities.find(e => e.getId() === this.selectedItemId); - } - - watch() { - return disposer( - reaction(() => this.entities, () => this.loadAll()), - reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100 }), - ); - } - - loadAll() { - if (this.activeCategory) { - this.activeCategory.emit("load"); + if (category) { + category.emit("load"); } else { - for (const category of catalogCategoryRegistry.items) { + for (const category of catalogRegistry.items) { category.emit("load"); } } + }; - // concurrency is true to fix bug if catalog filter is removed and added at the same time - return this.loadItems(() => this.entities, undefined, true); - } - - onRun(entity: CatalogEntity): void { - this.dependencies.registry.onRun(entity); - } + return { + entities, + selectedItem, + activeCategory, + selectedItemId, + watch: () => disposer( + reaction(() => entities.get(), loadAll), + reaction(() => activeCategory.get(), loadAll, { delay: 100 }), + ), + onRun: entity => entityRegistry.onRun(entity), + failedLoading: false, + getTotalCount: () => entities.get().length, + isLoaded: true, + isSelected: (item) => item.getId() === selectedItemId.get(), + isSelectedAll: () => false, + pickOnlySelected: () => [], + toggleSelection: () => {}, + toggleSelectionAll: () => {}, + removeSelectedItems: async () => {}, + }; } diff --git a/src/renderer/components/+catalog/catalog-menu.tsx b/src/renderer/components/+catalog/catalog-menu.tsx index d9bbb71613..8a672ddb44 100644 --- a/src/renderer/components/+catalog/catalog-menu.tsx +++ b/src/renderer/components/+catalog/catalog-menu.tsx @@ -18,7 +18,7 @@ import { observer } from "mobx-react"; import { CatalogCategoryLabel } from "./catalog-category-label"; export interface CatalogMenuProps { - activeItem: string; + activeTab: string | undefined; onItemClick: (id: string) => void; } @@ -54,9 +54,14 @@ export const CatalogMenu = observer((props: CatalogMenuProps) => { defaultExpanded={["catalog"]} defaultCollapseIcon={} defaultExpandIcon={} - selected={props.activeItem || "browse"} + selected={props.activeTab || "browse"} > - props.onItemClick("*")}/> + props.onItemClick("*")} + /> Categories
} diff --git a/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts b/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts index e0ab887393..36c57ba29a 100644 --- a/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts +++ b/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts @@ -12,7 +12,7 @@ const catalogPreviousActiveTabStorageInjectable = getInjectable({ instantiate: (di) => { const createStorage = di.inject(createStorageInjectable); - return createStorage( + return createStorage( "catalog-previous-active-tab", browseCatalogTab, ); diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx index 9a481d9535..808226727c 100644 --- a/src/renderer/components/+catalog/catalog.test.tsx +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -9,24 +9,24 @@ import userEvent from "@testing-library/user-event"; import { Catalog } from "./catalog"; import { mockWindow } from "../../../../__mocks__/windowMock"; import type { CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog"; -import { CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; -import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { CatalogEntity } from "../../../common/catalog"; +import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { DiContainer } from "@ogre-tools/injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; -import catalogEntityRegistryInjectable from "../../api/catalog-entity-registry/catalog-entity-registry.injectable"; +import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable"; import type { DiRender } from "../test-utils/renderFor"; import { renderFor } from "../test-utils/renderFor"; -import { ThemeStore } from "../../theme.store"; -import { UserStore } from "../../../common/user-store"; import mockFs from "mock-fs"; import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; import type { AppEvent } from "../../../common/app-event-bus/event-bus"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; +import { computed } from "mobx"; +import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable"; mockWindow(); jest.mock("electron", () => ({ @@ -60,55 +60,50 @@ class MockCatalogEntity extends CatalogEntity { constructor(data: CatalogEntityData, public onRun: (context: CatalogEntityActionContext) => void | Promise) { super(data); } +} - public onContextMenuOpen(): void | Promise {} - public onSettingsOpen(): void | Promise {} +function createMockCatalogEntity(onRun: (context: CatalogEntityActionContext) => void | Promise) { + return new MockCatalogEntity({ + metadata: { + uid: "a_catalogEntity_uid", + name: "a catalog entity", + labels: { + test: "label", + }, + }, + status: { + phase: "", + }, + spec: {}, + }, onRun); } describe("", () => { - function createMockCatalogEntity(onRun: (context: CatalogEntityActionContext) => void | Promise) { - return new MockCatalogEntity({ - metadata: { - uid: "a_catalogEntity_uid", - name: "a catalog entity", - labels: { - test: "label", - }, - }, - status: { - phase: "", - }, - spec: {}, - }, onRun); - } - let di: DiContainer; let catalogEntityStore: CatalogEntityStore; let catalogEntityRegistry: CatalogEntityRegistry; let emitEvent: (event: AppEvent) => void; + let onRun: jest.MockedFunction<(context: CatalogEntityActionContext) => void | Promise>; + let catalogEntityItem: MockCatalogEntity; let render: DiRender; - beforeEach(async () => { + beforeEach(() => { di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(broadcastMessageInjectable, () => async () => {}); + di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); - await di.runSetups(); - mockFs(); - - UserStore.createInstance(); - ThemeStore.createInstance(); CatalogEntityDetailRegistry.createInstance(); render = renderFor(di); - - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - - catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + onRun = jest.fn(); + catalogEntityItem = createMockCatalogEntity(onRun); + catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); di.override(catalogEntityRegistryInjectable, () => catalogEntityRegistry); @@ -119,27 +114,19 @@ describe("", () => { })); catalogEntityStore = di.inject(catalogEntityStoreInjectable); + Object.assign(catalogEntityStore, { + selectedItem: computed(() => catalogEntityItem), + }); }); afterEach(() => { - UserStore.resetInstance(); - ThemeStore.resetInstance(); CatalogEntityDetailRegistry.resetInstance(); - jest.clearAllMocks(); jest.restoreAllMocks(); mockFs.restore(); }); it("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( (event) => { expect(event.target.getId()).toBe("a_catalogEntity_uid"); @@ -152,22 +139,12 @@ describe("", () => { }, ); - render( - , - ); + render(); userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); }); it("onBeforeRun prevents event => onRun wont be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( (e) => { setTimeout(() => { @@ -184,14 +161,6 @@ describe("", () => { }); it("addOnBeforeRun throw an exception => onRun will be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( () => { setTimeout(() => { @@ -209,13 +178,7 @@ describe("", () => { }); it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => { - const onRun = jest.fn(() => done()); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); + onRun.mockImplementation(() => done()); catalogEntityRegistry.addOnBeforeRun( async () => { @@ -229,14 +192,6 @@ describe("", () => { }); it("addOnRunHook return a promise and prevents event wont be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( async (e) => { expect(onRun).not.toBeCalled(); @@ -256,14 +211,6 @@ describe("", () => { }); it("addOnRunHook return a promise and reject => onRun will be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( async () => { setTimeout(() => { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index adc9820f11..30a87f77a6 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -12,13 +12,12 @@ import type { IComputedValue } from "mobx"; import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import { MenuItem, MenuActions } from "../menu"; -import type { CatalogEntityContextMenuContext } from "../../api/catalog-entity"; -import type { HotbarStore } from "../../../common/hotbar-store"; -import type { CatalogEntity } from "../../../common/catalog"; -import { catalogCategoryRegistry } from "../../../common/catalog"; +import type { CatalogEntityContextMenu } from "../../api/catalog-entity"; +import type { CatalogCategory, CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; import { Notifications } from "../notifications"; import { MainLayout } from "../layout/main-layout"; +import type { StorageLayer } from "../../utils"; import { prevDefault } from "../../utils"; import { CatalogEntityDetails } from "./catalog-entity-details"; import { CatalogMenu } from "./catalog-menu"; @@ -29,8 +28,8 @@ import { Avatar } from "../avatar"; import { withInjectables } from "@ogre-tools/injectable-react"; import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; -import type { GetCategoryColumnsParams, CategoryColumns } from "./get-category-columns.injectable"; -import getCategoryColumnsInjectable from "./get-category-columns.injectable"; +import type { GetCategoryColumnsParams, CategoryColumns } from "./columns/get.injectable"; +import getCategoryColumnsInjectable from "./columns/get.injectable"; import type { RegisteredCustomCategoryViewDecl } from "./custom-views.injectable"; import customCategoryViewsInjectable from "./custom-views.injectable"; import type { CustomCategoryViewComponents } from "./custom-views"; @@ -40,14 +39,18 @@ import catalogRouteParametersInjectable from "./catalog-route-parameters.injecta import { browseCatalogTab } from "./catalog-browse-tab"; import type { AppEvent } from "../../../common/app-event-bus/event-bus"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; -import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; +import type { HotbarStore } from "../../../common/hotbars/store"; +import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable"; +import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable"; +import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable"; import type { Navigate } from "../../navigation/navigate.injectable"; import navigateInjectable from "../../navigation/navigate.injectable"; import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable"; interface Dependencies { - catalogPreviousActiveTabStorage: { set: (value: string ) => void; get: () => string }; + catalogPreviousActiveTabStorage: StorageLayer; catalogEntityStore: CatalogEntityStore; getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; customCategoryViews: IComputedValue>>; @@ -58,13 +61,15 @@ interface Dependencies { }; navigateToCatalog: NavigateToCatalog; hotbarStore: HotbarStore; + catalogCategoryRegistry: CatalogCategoryRegistry; + visitEntityContextMenu: VisitEntityContextMenu; navigate: Navigate; normalizeMenuItem: NormalizeCatalogEntityContextMenu; } @observer class NonInjectedCatalog extends React.Component { - @observable private contextMenu: CatalogEntityContextMenuContext; + private readonly menuItems = observable.array(); @observable activeTab?: string; constructor(props: Dependencies) { @@ -93,42 +98,50 @@ class NonInjectedCatalog extends React.Component { } async componentDidMount() { - this.contextMenu = { - menuItems: observable.array([]), - navigate: this.props.navigate, - }; + const { + catalogEntityStore, + catalogPreviousActiveTabStorage, + catalogCategoryRegistry, + } = this.props; + disposeOnUnmount(this, [ - this.props.catalogEntityStore.watch(), + catalogEntityStore.watch(), reaction(() => this.routeActiveTab, async (routeTab) => { + catalogPreviousActiveTabStorage.set(this.routeActiveTab); + try { await when(() => (routeTab === browseCatalogTab || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab); runInAction(() => { this.activeTab = routeTab; - this.props.catalogEntityStore.activeCategory = item; + catalogEntityStore.activeCategory.set(item); }); } catch (error) { console.error(error); - Notifications.error(

Unknown category: {routeTab}

); + Notifications.error(( +

+ {"Unknown category: "} + {routeTab} +

+ )); } }, { fireImmediately: true }), + // If active category is filtered out, automatically switch to the first category + reaction(() => catalogCategoryRegistry.filteredItems, () => { + if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === catalogEntityStore.activeCategory.get()?.getId())) { + const item = catalogCategoryRegistry.filteredItems[0]; + + runInAction(() => { + if (item) { + this.activeTab = item.getId(); + this.props.catalogEntityStore.activeCategory.set(item); + } + }); + } + }), ]); - // If active category is filtered out, automatically switch to the first category - disposeOnUnmount(this, reaction(() => catalogCategoryRegistry.filteredItems, () => { - if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === this.props.catalogEntityStore.activeCategory.getId())) { - const item = catalogCategoryRegistry.filteredItems[0]; - - runInAction(() => { - if (item) { - this.activeTab = item.getId(); - this.props.catalogEntityStore.activeCategory = item; - } - }); - } - })); - this.props.emitEvent({ name: "catalog", action: "open", @@ -144,15 +157,15 @@ class NonInjectedCatalog extends React.Component { } onDetails = (entity: CatalogEntity) => { - if (this.props.catalogEntityStore.selectedItemId) { - this.props.catalogEntityStore.selectedItemId = null; + if (this.props.catalogEntityStore.selectedItemId.get()) { + this.props.catalogEntityStore.selectedItemId.set(undefined); } else { this.props.catalogEntityStore.onRun(entity); } }; get categories() { - return catalogCategoryRegistry.items; + return this.props.catalogCategoryRegistry.items; } onTabChange = action((tabId: string | null) => { @@ -175,26 +188,25 @@ class NonInjectedCatalog extends React.Component { } }); - renderNavigation() { - return ( - - ); - } - renderItemMenu = (entity: CatalogEntity) => { const onOpen = () => { - this.contextMenu.menuItems = []; - - entity.onContextMenuOpen(this.contextMenu); + this.menuItems.clear(); + this.props.visitEntityContextMenu(entity, { + menuItems: this.menuItems, + navigate: this.props.navigate, + }); }; return ( - this.props.catalogEntityStore.selectedItemId = entity.getId()}> + this.props.catalogEntityStore.selectedItemId.set(entity.getId())} + > View Details { - this.contextMenu.menuItems + this.menuItems .map(this.props.normalizeMenuItem) .map((menuItem, index) => ( @@ -231,7 +243,6 @@ class NonInjectedCatalog extends React.Component { isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))} @@ -240,15 +251,12 @@ class NonInjectedCatalog extends React.Component { ); } - renderViews = () => { - const { catalogEntityStore, customCategoryViews } = this.props; - const { activeCategory } = catalogEntityStore; - + renderViews = (activeCategory: CatalogCategory | undefined) => { if (!activeCategory) { - return this.renderList(); + return this.renderList(activeCategory); } - const customViews = customCategoryViews.get() + const customViews = this.props.customCategoryViews.get() .get(activeCategory.spec.group) ?.get(activeCategory.spec.names.kind); const renderView = ({ View }: CustomCategoryViewComponents, index: number) => ( @@ -261,15 +269,14 @@ class NonInjectedCatalog extends React.Component { return ( <> {customViews?.before.map(renderView)} - {this.renderList()} + {this.renderList(activeCategory)} {customViews?.after.map(renderView)} ); }; - renderList() { + renderList(activeCategory: CatalogCategory | undefined) { const { catalogEntityStore, getCategoryColumns } = this.props; - const { activeCategory } = catalogEntityStore; const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items"; @@ -279,14 +286,15 @@ class NonInjectedCatalog extends React.Component { } return ( - className={styles.Catalog} tableId={tableId} renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"} isSelectable={false} isConfigurable={true} + preloadStores={false} store={catalogEntityStore} - getItems={() => catalogEntityStore.entities} + getItems={() => catalogEntityStore.entities.get()} customizeTableRowProps={entity => ({ disabled: !entity.isEnabled(), })} @@ -302,27 +310,37 @@ class NonInjectedCatalog extends React.Component { return null; } - const selectedEntity = this.props.catalogEntityStore.selectedItem; + const activeCategory = this.props.catalogEntityStore.activeCategory.get(); + const selectedItem = this.props.catalogEntityStore.selectedItem.get(); return ( - + + )} + >
- {this.renderViews()} + {this.renderViews(activeCategory)}
{ - selectedEntity - ? this.props.catalogEntityStore.selectedItemId = null} - onRun={() => this.props.catalogEntityStore.onRun(selectedEntity)} - /> - : ( - - - + selectedItem + ? ( + this.props.catalogEntityStore.selectedItemId.set(undefined)} + onRun={() => this.props.catalogEntityStore.onRun(selectedItem)} + /> ) + : activeCategory + ? ( + + + + ) + : null }
); @@ -340,6 +358,8 @@ export const Catalog = withInjectables(NonInjectedCatalog, { navigateToCatalog: di.inject(navigateToCatalogInjectable), emitEvent: di.inject(appEventBusInjectable).emit, hotbarStore: di.inject(hotbarStoreInjectable), + catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable), + visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable), navigate: di.inject(navigateInjectable), normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable), }), diff --git a/src/renderer/components/+catalog/columns/browse-all.injectable.tsx b/src/renderer/components/+catalog/columns/browse-all.injectable.tsx new file mode 100644 index 0000000000..b7e1ddddb7 --- /dev/null +++ b/src/renderer/components/+catalog/columns/browse-all.injectable.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; +import { getInjectable } from "@ogre-tools/injectable"; +import namedCategoryColumnInjectable from "./named-category.injectable"; +import defaultCategoryColumnsInjectable from "./default-category.injectable"; + +const defaultBrowseAllColumns: RegisteredAdditionalCategoryColumn[] = [ + { + id: "kind", + priority: 5, + renderCell: entity => entity.kind, + titleProps: { + id: "kind", + sortBy: "kind", + title: "Kind", + }, + sortCallback: entity => entity.kind, + }, +]; + +const browseAllColumnsInjectable = getInjectable({ + id: "browse-all-columns", + instantiate: (di) => [ + ...defaultBrowseAllColumns, + di.inject(namedCategoryColumnInjectable), + ...di.inject(defaultCategoryColumnsInjectable), + ], +}); + +export default browseAllColumnsInjectable; + diff --git a/src/renderer/components/+catalog/columns/default-category.injectable.tsx b/src/renderer/components/+catalog/columns/default-category.injectable.tsx new file mode 100644 index 0000000000..00df70cdbc --- /dev/null +++ b/src/renderer/components/+catalog/columns/default-category.injectable.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "../catalog.module.scss"; +import React from "react"; +import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; +import { KubeObject } from "../../../../common/k8s-api/kube-object"; +import { getInjectable } from "@ogre-tools/injectable"; +import getLabelBadgesInjectable from "../get-label-badges.injectable"; + +const defaultCategoryColumnsInjectable = getInjectable({ + id: "default-category-columns", + instantiate: (di): RegisteredAdditionalCategoryColumn[] => { + const getLabelBadges = di.inject(getLabelBadgesInjectable); + + return [ + { + id: "source", + priority: 10, + renderCell: entity => entity.getSource(), + titleProps: { + title: "Source", + className: styles.sourceCell, + id: "source", + sortBy: "source", + }, + sortCallback: entity => entity.getSource(), + searchFilter: entity => `source=${entity.getSource()}`, + }, + { + id: "labels", + priority: 20, + renderCell: getLabelBadges, + titleProps: { + id: "labels", + title: "Labels", + className: `${styles.labelsCell} scrollable`, + }, + searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels), + }, + { + id: "status", + priority: 30, + renderCell: entity => ( + + {entity.status.phase} + + ), + titleProps: { + title: "Status", + className: styles.statusCell, + id: "status", + sortBy: "status", + }, + searchFilter: entity => entity.status.phase, + sortCallback: entity => entity.status.phase, + }, + ]; + }, +}); + +export default defaultCategoryColumnsInjectable; diff --git a/src/renderer/components/+catalog/columns/get.injectable.ts b/src/renderer/components/+catalog/columns/get.injectable.ts new file mode 100644 index 0000000000..03930578c1 --- /dev/null +++ b/src/renderer/components/+catalog/columns/get.injectable.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { orderBy } from "lodash"; +import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog"; +import type { ItemListLayoutProps } from "../../item-object-list"; +import browseAllColumnsInjectable from "./browse-all.injectable"; +import getColumnsForCategoryInjectable from "./specific-category.injectable"; + +export interface GetCategoryColumnsParams { + activeCategory: CatalogCategory | null | undefined; +} + +export type CategoryColumns = Required, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>; +export type GetCategoryColumns = (params: GetCategoryColumnsParams) => CategoryColumns; + +const getCategoryColumnsInjectable = getInjectable({ + id: "get-category-columns", + + instantiate: (di): GetCategoryColumns => { + const getColumnsForCategory = di.inject(getColumnsForCategoryInjectable); + const browseAllColumns = di.inject(browseAllColumnsInjectable); + + return ({ activeCategory }) => { + const allRegistrations = orderBy( + activeCategory + ? getColumnsForCategory(activeCategory) + : browseAllColumns, + "priority", + "asc", + ); + + const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {}; + const searchFilters: CategoryColumns["searchFilters"] = []; + const renderTableHeader: CategoryColumns["renderTableHeader"] = []; + const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = []; + + for (const registration of allRegistrations) { + if (registration.sortCallback) { + sortingCallbacks[registration.id] = registration.sortCallback; + } + + if (registration.searchFilter) { + searchFilters.push(registration.searchFilter); + } + + tableRowRenderers.push(registration.renderCell); + renderTableHeader.push(registration.titleProps); + } + + return { + sortingCallbacks, + renderTableHeader, + renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)), + searchFilters, + }; + }; + }, +}); + +export default getCategoryColumnsInjectable; diff --git a/src/renderer/components/+catalog/name-category-column.injectable.tsx b/src/renderer/components/+catalog/columns/named-category.injectable.tsx similarity index 73% rename from src/renderer/components/+catalog/name-category-column.injectable.tsx rename to src/renderer/components/+catalog/columns/named-category.injectable.tsx index 27fd4072a0..1f782d7b6f 100644 --- a/src/renderer/components/+catalog/name-category-column.injectable.tsx +++ b/src/renderer/components/+catalog/columns/named-category.injectable.tsx @@ -3,15 +3,15 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import styles from "./catalog.module.scss"; -import type { CatalogEntity } from "../../../common/catalog"; -import { prevDefault } from "../../utils"; -import { Avatar } from "../avatar"; -import { Icon } from "../icon"; +import styles from "../catalog.module.scss"; +import type { CatalogEntity } from "../../../../common/catalog"; +import { prevDefault } from "../../../utils"; +import { Avatar } from "../../avatar"; +import { Icon } from "../../icon"; import React from "react"; -import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; -import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; -import type { HotbarStore } from "../../../common/hotbar-store"; +import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; +import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; +import type { HotbarStore } from "../../../../common/hotbars/store"; const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) => { const isItemInHotbar = hotbarStore.isAddedToActive(entity); @@ -37,7 +37,6 @@ const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) = (entity: CatalogEntity) = ); }; - -const nameCategoryColumnInjectable = getInjectable({ +const namedCategoryColumnInjectable = getInjectable({ id: "name-category-column", instantiate: (di): RegisteredAdditionalCategoryColumn => ({ id: "name", @@ -64,4 +62,4 @@ const nameCategoryColumnInjectable = getInjectable({ }), }); -export default nameCategoryColumnInjectable; +export default namedCategoryColumnInjectable; diff --git a/src/renderer/components/+catalog/columns/specific-category.injectable.ts b/src/renderer/components/+catalog/columns/specific-category.injectable.ts new file mode 100644 index 0000000000..94f0508612 --- /dev/null +++ b/src/renderer/components/+catalog/columns/specific-category.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogCategory } from "../../../api/catalog-entity"; +import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; +import categoryColumnsInjectable from "../custom-category-columns.injectable"; +import defaultCategoryColumnsInjectable from "./default-category.injectable"; +import namedCategoryColumnInjectable from "./named-category.injectable"; + +export type GetColumnsForCategory = (activeCategory: CatalogCategory) => RegisteredAdditionalCategoryColumn[]; + +const getColumnsForCategoryInjectable = getInjectable({ + id: "get-columns-for-category", + instantiate: (di): GetColumnsForCategory => { + const extensionColumns = di.inject(categoryColumnsInjectable); + const defaultCategoryColumns = di.inject(defaultCategoryColumnsInjectable); + const nameCategoryColumn = di.inject(namedCategoryColumnInjectable); + + return (activeCategory) => { + const fromExtensions = ( + extensionColumns + .get() + .get(activeCategory.spec.group) + ?.get(activeCategory.spec.names.kind) + ?? [] + ); + const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({ + priority, + ...column, + })) ?? defaultCategoryColumns; + + return [ + nameCategoryColumn, + ...fromExtensions, + ...fromCategory, + ]; + }; + }, +}); + +export default getColumnsForCategoryInjectable; diff --git a/src/renderer/components/+catalog/get-category-columns.injectable.ts b/src/renderer/components/+catalog/get-category-columns.injectable.ts deleted file mode 100644 index bf2cee1bb9..0000000000 --- a/src/renderer/components/+catalog/get-category-columns.injectable.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { orderBy } from "lodash"; -import type { IComputedValue } from "mobx"; -import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; -import type { ItemListLayoutProps } from "../item-object-list"; -import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; -import categoryColumnsInjectable from "./custom-category-columns.injectable"; -import { defaultCategoryColumns, browseAllColumns } from "./internal-category-columns"; -import nameCategoryColumnInjectable from "./name-category-column.injectable"; - -interface Dependencies { - extensionColumns: IComputedValue>>; - nameCategoryColumn: RegisteredAdditionalCategoryColumn; -} - -export interface GetCategoryColumnsParams { - activeCategory: CatalogCategory | null | undefined; -} - -export type CategoryColumns = Required, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>; - -function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue>>, nameCategoryColumn: RegisteredAdditionalCategoryColumn): RegisteredAdditionalCategoryColumn[] { - const fromExtensions = ( - extensionColumns - .get() - .get(activeCategory.spec.group) - ?.get(activeCategory.spec.names.kind) - ?? [] - ); - const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({ - priority, - ...column, - })) ?? defaultCategoryColumns; - - return [ - nameCategoryColumn, - ...fromExtensions, - ...fromCategory, - ]; -} - -function getBrowseAllColumns(nameCategoryColumn: RegisteredAdditionalCategoryColumn): RegisteredAdditionalCategoryColumn[] { - return [ - ...browseAllColumns, - nameCategoryColumn, - ...defaultCategoryColumns, - ]; -} - -const getCategoryColumns = ({ extensionColumns, nameCategoryColumn }: Dependencies) => ({ activeCategory }: GetCategoryColumnsParams): CategoryColumns => { - const allRegistrations = orderBy( - activeCategory - ? getSpecificCategoryColumns(activeCategory, extensionColumns, nameCategoryColumn) - : getBrowseAllColumns(nameCategoryColumn), - "priority", - "asc", - ); - - const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {}; - const searchFilters: CategoryColumns["searchFilters"] = []; - const renderTableHeader: CategoryColumns["renderTableHeader"] = []; - const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = []; - - for (const registration of allRegistrations) { - if (registration.sortCallback) { - sortingCallbacks[registration.id] = registration.sortCallback; - } - - if (registration.searchFilter) { - searchFilters.push(registration.searchFilter); - } - - tableRowRenderers.push(registration.renderCell); - renderTableHeader.push(registration.titleProps); - } - - return { - sortingCallbacks, - renderTableHeader, - renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)), - searchFilters, - }; -}; - -const getCategoryColumnsInjectable = getInjectable({ - id: "get-category-columns", - - instantiate: (di) => getCategoryColumns({ - extensionColumns: di.inject(categoryColumnsInjectable), - nameCategoryColumn: di.inject(nameCategoryColumnInjectable), - }), -}); - -export default getCategoryColumnsInjectable; diff --git a/src/renderer/components/+catalog/get-label-badges.injectable.tsx b/src/renderer/components/+catalog/get-label-badges.injectable.tsx new file mode 100644 index 0000000000..b75e501f90 --- /dev/null +++ b/src/renderer/components/+catalog/get-label-badges.injectable.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import styles from "./catalog.module.scss"; +import React from "react"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogEntity } from "../../api/catalog-entity"; +import searchUrlPageParamInjectable from "../input/search-url-page-param.injectable"; +import { KubeObject } from "../../../common/k8s-api/kube-object"; +import { Badge } from "../badge"; + +export type GetLabelBadges = (entity: CatalogEntity, onClick?: (evt: React.MouseEvent) => void) => JSX.Element[]; + +const getLabelBadgesInjectable = getInjectable({ + id: "get-label-badges", + instantiate: (di): GetLabelBadges => { + const searchUrlParam = di.inject(searchUrlPageParamInjectable); + + return (entity, onClick) => ( + KubeObject.stringifyLabels(entity.metadata.labels) + .map(label => ( + { + searchUrlParam.set(label); + onClick?.(event); + event.stopPropagation(); + }} + expandable={false} + /> + )) + ); + }, +}); + +export default getLabelBadgesInjectable; diff --git a/src/renderer/components/+catalog/helpers.tsx b/src/renderer/components/+catalog/helpers.tsx deleted file mode 100644 index 5ee2a76da5..0000000000 --- a/src/renderer/components/+catalog/helpers.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import styles from "./catalog.module.scss"; -import React from "react"; -import { KubeObject } from "../../../common/k8s-api/kube-object"; -import type { CatalogEntity } from "../../api/catalog-entity"; -import { Badge } from "../badge"; -import { searchUrlParam } from "../input"; - -/** - * @param entity The entity to render badge labels for - */ -export function getLabelBadges(entity: CatalogEntity, onClick?: (evt: React.MouseEvent) => void) { - return KubeObject.stringifyLabels(entity.metadata.labels) - .map(label => ( - { - searchUrlParam.set(label); - onClick?.(event); - event.stopPropagation(); - }} - expandable={false} - /> - )); -} diff --git a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx index 0958213658..bae8895ff7 100644 --- a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx +++ b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx @@ -9,17 +9,17 @@ import { MenuItem } from "../menu"; import type { CatalogEntity } from "../../api/catalog-entity"; import { withInjectables } from "@ogre-tools/injectable-react"; -import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; -import type { HotbarStore } from "../../../common/hotbar-store"; +import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; +import type { HotbarStore } from "../../../common/hotbars/store"; interface Dependencies { hotbarStore: HotbarStore; } interface HotbarToggleMenuItemProps { - entity: CatalogEntity; - addContent: ReactNode; - removeContent: ReactNode; + entity: CatalogEntity; + addContent: ReactNode; + removeContent: ReactNode; } function NonInjectedHotbarToggleMenuItem({ @@ -31,15 +31,17 @@ function NonInjectedHotbarToggleMenuItem({ const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity)); return ( - { - if (itemInHotbar) { - hotbarStore.removeFromHotbar(entity.getId()); - setItemInHotbar(false); - } else { - hotbarStore.addToHotbar(entity); - setItemInHotbar(true); - } - }}> + { + if (itemInHotbar) { + hotbarStore.removeFromHotbar(entity.getId()); + setItemInHotbar(false); + } else { + hotbarStore.addToHotbar(entity); + setItemInHotbar(true); + } + }} + > {itemInHotbar ? removeContent : addContent } ); diff --git a/src/renderer/components/+catalog/internal-category-columns.tsx b/src/renderer/components/+catalog/internal-category-columns.tsx deleted file mode 100644 index 0aba81db0b..0000000000 --- a/src/renderer/components/+catalog/internal-category-columns.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import styles from "./catalog.module.scss"; - -import React from "react"; -import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; -import { getLabelBadges } from "./helpers"; -import { KubeObject } from "../../../common/k8s-api/kube-object"; - -export const browseAllColumns: RegisteredAdditionalCategoryColumn[] = [ - { - id: "kind", - priority: 5, - renderCell: entity => entity.kind, - titleProps: { - id: "kind", - sortBy: "kind", - title: "Kind", - }, - sortCallback: entity => entity.kind, - }, -]; - -export const defaultCategoryColumns: RegisteredAdditionalCategoryColumn[] = [ - { - id: "source", - priority: 10, - renderCell: entity => entity.getSource(), - titleProps: { - title: "Source", - className: styles.sourceCell, - id: "source", - sortBy: "source", - }, - sortCallback: entity => entity.getSource(), - searchFilter: entity => `source=${entity.getSource()}`, - }, - { - id: "labels", - priority: 20, - renderCell: getLabelBadges, - titleProps: { - id: "labels", - title: "Labels", - className: `${styles.labelsCell} scrollable`, - }, - searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels), - }, - { - id: "status", - priority: 30, - renderCell: entity => ( - - {entity.status.phase} - - ), - titleProps: { - title: "Status", - className: styles.statusCell, - id: "status", - sortBy: "status", - }, - searchFilter: entity => entity.status.phase, - sortCallback: entity => entity.status.phase, - }, -]; diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/cluster-issues.tsx index a2555feefd..559c54a365 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/cluster-issues.tsx @@ -11,23 +11,31 @@ import { computed, makeObservable } from "mobx"; import { Icon } from "../icon"; import { SubHeader } from "../layout/sub-header"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { nodesStore } from "../+nodes/nodes.store"; -import { eventStore } from "../+events/event.store"; import { cssNames, prevDefault } from "../../utils"; import type { ItemObject } from "../../../common/item.store"; import { Spinner } from "../spinner"; -import { ThemeStore } from "../../theme.store"; -import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ThemeStore } from "../../themes/store"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; import { KubeObjectAge } from "../kube-object/age"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import themeStoreInjectable from "../../themes/store.injectable"; +import type { NodeStore } from "../+nodes/store"; +import type { EventStore } from "../+events/store"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; +import nodeStoreInjectable from "../+nodes/store.injectable"; +import type { PageParam } from "../../navigation"; +import type { ToggleKubeDetailsPane } from "../kube-detail-params/toggle-details.injectable"; +import kubeSelectedUrlParamInjectable from "../kube-detail-params/kube-selected-url.injectable"; +import toggleKubeDetailsPaneInjectable from "../kube-detail-params/toggle-details.injectable"; export interface ClusterIssuesProps { className?: string; } -interface IWarning extends ItemObject { +interface Warning extends ItemObject { kind: string; - message: string; + message: string | undefined; selfLink: string; renderAge: () => React.ReactElement; ageMs: number; @@ -39,16 +47,25 @@ enum sortBy { age = "age", } +interface Dependencies { + themeStore: ThemeStore; + nodeStore: NodeStore; + eventStore: EventStore; + apiManager: ApiManager; + kubeSelectedUrlParam: PageParam; + toggleKubeDetailsPane: ToggleKubeDetailsPane; +} + @observer -export class ClusterIssues extends React.Component { - constructor(props: ClusterIssuesProps) { +class NonInjectedClusterIssues extends React.Component { + constructor(props: ClusterIssuesProps & Dependencies) { super(props); makeObservable(this); } - @computed get warnings(): IWarning[] { + @computed get warnings(): Warning[] { return [ - ...nodesStore.items.flatMap(node => ( + ...this.props.nodeStore.items.flatMap(node => ( node.getWarningConditions() .map(({ message }) => ({ selfLink: node.selfLink, @@ -60,21 +77,27 @@ export class ClusterIssues extends React.Component { ageMs: -node.getCreationTimestamp(), })) )), - ...eventStore.getWarnings().map(warning => ({ + ...this.props.eventStore.getWarnings().map(warning => ({ getId: () => warning.involvedObject.uid, getName: () => warning.involvedObject.name, renderAge: () => , ageMs: -warning.getCreationTimestamp(), message: warning.message, kind: warning.kind, - selfLink: apiManager.lookupApiLink(warning.involvedObject, warning), + selfLink: this.props.apiManager.lookupApiLink(warning.involvedObject, warning), })), ]; } getTableRow = (uid: string) => { const { warnings } = this; + const { kubeSelectedUrlParam, toggleKubeDetailsPane: toggleDetails } = this.props; const warning = warnings.find(warn => warn.getId() == uid); + + if (!warning) { + return undefined; + } + const { getId, getName, message, kind, selfLink, renderAge } = warning; return ( @@ -85,7 +108,7 @@ export class ClusterIssues extends React.Component { onClick={prevDefault(() => toggleDetails(selfLink))} > - {message} + {message ?? ""} {getName()} @@ -103,7 +126,7 @@ export class ClusterIssues extends React.Component { renderContent() { const { warnings } = this; - if (!eventStore.isLoaded) { + if (!this.props.eventStore.isLoaded) { return ( ); @@ -112,7 +135,12 @@ export class ClusterIssues extends React.Component { if (!warnings.length) { return (
- +

No issues found

Everything is fine in the Cluster

@@ -122,7 +150,8 @@ export class ClusterIssues extends React.Component { return ( <> - Warnings: {warnings.length} + + {` Warnings: ${warnings.length}`} { sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }} sortSyncWithUrl={false} getTableRow={this.getTableRow} - className={cssNames("box grow", ThemeStore.getInstance().activeTheme.type)} + className={cssNames("box grow", this.props.themeStore.activeTheme.type)} > Message @@ -158,3 +187,15 @@ export class ClusterIssues extends React.Component { ); } } + +export const ClusterIssues = withInjectables(NonInjectedClusterIssues, { + getProps: (di, props) => ({ + ...props, + themeStore: di.inject(themeStoreInjectable), + apiManager: di.inject(apiManagerInjectable), + eventStore: di.inject(eventStoreInjectable), + nodeStore: di.inject(nodeStoreInjectable), + kubeSelectedUrlParam: di.inject(kubeSelectedUrlParamInjectable), + toggleKubeDetailsPane: di.inject(toggleKubeDetailsPaneInjectable), + }), +}); diff --git a/src/renderer/components/+cluster/cluster-metric-switchers.tsx b/src/renderer/components/+cluster/cluster-metric-switchers.tsx index 3a91d8cf20..32c3dce572 100644 --- a/src/renderer/components/+cluster/cluster-metric-switchers.tsx +++ b/src/renderer/components/+cluster/cluster-metric-switchers.tsx @@ -5,20 +5,25 @@ import React from "react"; import { observer } from "mobx-react"; -import { nodesStore } from "../+nodes/nodes.store"; +import type { NodeStore } from "../+nodes/store"; import { cssNames } from "../../utils"; import { Radio, RadioGroup } from "../radio"; import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store"; import { MetricNodeRole, MetricType } from "./cluster-overview-store/cluster-overview-store"; import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; +import nodeStoreInjectable from "../+nodes/store.injectable"; interface Dependencies { clusterOverviewStore: ClusterOverviewStore; + nodeStore: NodeStore; } -const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: Dependencies) => { - const { masterNodes, workerNodes } = nodesStore; +const NonInjectedClusterMetricSwitchers = observer(({ + clusterOverviewStore, + nodeStore, +}: Dependencies) => { + const { masterNodes, workerNodes } = nodeStore; const metricsValues = clusterOverviewStore.getMetricsValues(clusterOverviewStore.metrics); const disableRoles = !masterNodes.length || !workerNodes.length; const disableMetrics = !metricsValues.length; @@ -30,7 +35,7 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De asButtons className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })} value={clusterOverviewStore.metricNodeRole} - onChange={(metric: MetricNodeRole) => clusterOverviewStore.metricNodeRole = metric} + onChange={metric => clusterOverviewStore.metricNodeRole = metric} > @@ -41,7 +46,7 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De asButtons className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })} value={clusterOverviewStore.metricType} - onChange={(value: MetricType) => clusterOverviewStore.metricType = value} + onChange={value => clusterOverviewStore.metricType = value} > @@ -51,13 +56,10 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De ); }); -export const ClusterMetricSwitchers = withInjectables( - NonInjectedClusterMetricSwitchers, - - { - getProps: (di) => ({ - clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), - }, -); +export const ClusterMetricSwitchers = withInjectables(NonInjectedClusterMetricSwitchers, { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), + nodeStore: di.inject(nodeStoreInjectable), + }), +}); diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/cluster-metrics.tsx index fd69842c41..e744383a94 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/cluster-metrics.tsx @@ -5,7 +5,7 @@ import styles from "./cluster-metrics.module.scss"; -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import type { ChartOptions, ChartPoint } from "chart.js"; import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store"; @@ -13,19 +13,19 @@ import { MetricType } from "./cluster-overview-store/cluster-overview-store"; import { BarChart } from "../chart"; import { bytesToUnits, cssNames } from "../../utils"; import { Spinner } from "../spinner"; -import { ZebraStripes } from "../chart/zebra-stripes.plugin"; +import { ZebraStripesPlugin } from "../chart/zebra-stripes.plugin"; import { ClusterNoMetrics } from "./cluster-no-metrics"; import { ClusterMetricSwitchers } from "./cluster-metric-switchers"; import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api"; import { withInjectables } from "@ogre-tools/injectable-react"; -import clusterOverviewStoreInjectable - from "./cluster-overview-store/cluster-overview-store.injectable"; +import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; interface Dependencies { clusterOverviewStore: ClusterOverviewStore; } const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => { + const [plugins] = useState([new ZebraStripesPlugin()]); const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics); const metricValues = getMetricsValues(metrics); const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; @@ -52,9 +52,13 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType tooltips: { callbacks: { label: ({ index }, data) => { - const value = data.datasets[0].data[index] as ChartPoint; + if (!index) { + return ""; + } - return value.y.toString(); + const value = data.datasets?.[0].data?.[index] as ChartPoint; + + return value.y?.toString() ?? ""; }, }, }, @@ -71,7 +75,11 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType tooltips: { callbacks: { label: ({ index }, data) => { - const value = data.datasets[0].data[index] as ChartPoint; + if (!index) { + return ""; + } + + const value = data.datasets?.[0].data?.[index] as ChartPoint; return bytesToUnits(parseInt(value.y as string), { precision: 3 }); }, @@ -96,7 +104,7 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType data={{ datasets }} timeLabelStep={5} showLegend={false} - plugins={[ZebraStripes]} + plugins={plugins} className={styles.chart} /> ); diff --git a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts index f80fdcf4ee..b81cb5c3ac 100644 --- a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts +++ b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts @@ -3,40 +3,36 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { - ClusterOverviewStorageState } from "./cluster-overview-store"; -import { - ClusterOverviewStore, - MetricNodeRole, - MetricType, -} from "./cluster-overview-store"; + +import type { ClusterOverviewStorageState } from "./cluster-overview-store"; +import { ClusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview-store"; import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; -import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable"; +import { kubeObjectStoreInjectionToken } from "../../../../common/k8s-api/api-manager/manager.injectable"; +import clusterApiInjectable from "../../../../common/k8s-api/endpoints/cluster.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../../stores-apis-can-be-created.injectable"; +import assert from "assert"; +import nodeStoreInjectable from "../../+nodes/store.injectable"; const clusterOverviewStoreInjectable = getInjectable({ id: "cluster-overview-store", instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "clusterOverviewStore is only available in certain environments"); const createStorage = di.inject(createStorageInjectable); + const clusterApi = di.inject(clusterApiInjectable); - const storage = createStorage( - "cluster_overview", - { - metricType: MetricType.CPU, // setup defaults - metricNodeRole: MetricNodeRole.WORKER, - }, - ); - - const store = new ClusterOverviewStore({ - storage, - }); - - const apiManager = di.inject(apiManagerInjectable); - - apiManager.registerStore(store); - - return store; + return new ClusterOverviewStore({ + storage: createStorage( + "cluster_overview", + { + metricType: MetricType.CPU, // setup defaults + metricNodeRole: MetricNodeRole.WORKER, + }, + ), + nodeStore: di.inject(nodeStoreInjectable), + }, clusterApi); }, + injectionToken: kubeObjectStoreInjectionToken, }); export default clusterOverviewStoreInjectable; diff --git a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts index 414f31ccbc..d3f47cb430 100644 --- a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts +++ b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts @@ -5,12 +5,12 @@ import { action, observable, reaction, when, makeObservable } from "mobx"; import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import type { Cluster } from "../../../../common/k8s-api/endpoints"; -import { clusterApi, getMetricsByNodeNames, type IClusterMetrics } from "../../../../common/k8s-api/endpoints"; -import type { StorageHelper } from "../../../utils"; +import type { Cluster, ClusterApi } from "../../../../common/k8s-api/endpoints"; +import { getMetricsByNodeNames, type ClusterMetricData } from "../../../../common/k8s-api/endpoints"; +import type { StorageLayer } from "../../../utils"; import { autoBind } from "../../../utils"; import { type IMetricsReqParams, normalizeMetrics } from "../../../../common/k8s-api/endpoints/metrics.api"; -import { nodesStore } from "../../+nodes/nodes.store"; +import type { NodeStore } from "../../+nodes/store"; export enum MetricType { MEMORY = "memory", @@ -27,14 +27,13 @@ export interface ClusterOverviewStorageState { metricNodeRole: MetricNodeRole; } -interface Dependencies { - storage: StorageHelper; +interface ClusterOverviewStoreDependencies { + readonly storage: StorageLayer; + readonly nodeStore: NodeStore; } -export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { - api = clusterApi; - - @observable metrics: Partial = {}; +export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { + @observable metrics: Partial = {}; @observable metricsLoaded = false; get metricType(): MetricType { @@ -53,8 +52,8 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl this.dependencies.storage.merge({ metricNodeRole: value }); } - constructor(private dependencies: Dependencies ) { - super(); + constructor(protected readonly dependencies: ClusterOverviewStoreDependencies, api: ClusterApi) { + super(api); makeObservable(this); autoBind(this); @@ -71,8 +70,8 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl }); // check which node type to select - reaction(() => nodesStore.items.length, () => { - const { masterNodes, workerNodes } = nodesStore; + reaction(() => this.dependencies.nodeStore.items.length, () => { + const { masterNodes, workerNodes } = this.dependencies.nodeStore; if (!masterNodes.length) this.metricNodeRole = MetricNodeRole.WORKER; if (!workerNodes.length) this.metricNodeRole = MetricNodeRole.MASTER; @@ -81,15 +80,15 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl @action async loadMetrics(params?: IMetricsReqParams) { - await when(() => nodesStore.isLoaded); - const { masterNodes, workerNodes } = nodesStore; + await when(() => this.dependencies.nodeStore.isLoaded); + const { masterNodes, workerNodes } = this.dependencies.nodeStore; const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; this.metrics = await getMetricsByNodeNames(nodes.map(node => node.getName()), params); this.metricsLoaded = true; } - getMetricsValues(source: Partial): [number, string][] { + getMetricsValues(source: Partial): [number, string][] { switch (this.metricType) { case MetricType.CPU: return normalizeMetrics(source.cpuUsage).data.result[0].values; diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index 36590df34b..f17579e843 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -8,29 +8,36 @@ import styles from "./cluster-overview.module.scss"; import React from "react"; import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { nodesStore } from "../+nodes/nodes.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { Disposer } from "../../utils"; -import { getHostedClusterId, interval } from "../../utils"; -import { TabLayout } from "../layout/tab-layout-2"; +import type { NodeStore } from "../+nodes/store"; +import type { PodStore } from "../+workloads-pods/store"; +import { interval } from "../../utils"; +import { TabLayout } from "../layout/tab-layout"; import { Spinner } from "../spinner"; import { ClusterIssues } from "./cluster-issues"; import { ClusterMetrics } from "./cluster-metrics"; import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store"; import { ClusterPieCharts } from "./cluster-pie-charts"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; +import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { eventStore } from "../+events/event.store"; +import type { EventStore } from "../+events/store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; +import type { Cluster } from "../../../common/cluster/cluster"; +import hostedClusterInjectable from "../../../common/cluster-store/hosted-cluster.injectable"; +import assert from "assert"; +import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable"; +import podStoreInjectable from "../+workloads-pods/store.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; +import nodeStoreInjectable from "../+nodes/store.injectable"; interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer; + subscribeStores: SubscribeStores; clusterOverviewStore: ClusterOverviewStore; + hostedCluster: Cluster; + podStore: PodStore; + eventStore: EventStore; + nodeStore: NodeStore; } @observer @@ -38,9 +45,7 @@ class NonInjectedClusterOverview extends React.Component { private metricPoller = interval(60, () => this.loadMetrics()); loadMetrics() { - const cluster = ClusterStore.getInstance().getById(getHostedClusterId()); - - if (cluster.available) { + if (this.props.hostedCluster.available) { this.props.clusterOverviewStore.loadMetrics(); } } @@ -50,9 +55,9 @@ class NonInjectedClusterOverview extends React.Component { disposeOnUnmount(this, [ this.props.subscribeStores([ - podsStore, - eventStore, - nodesStore, + this.props.podStore, + this.props.eventStore, + this.props.nodeStore, ]), reaction( @@ -66,7 +71,7 @@ class NonInjectedClusterOverview extends React.Component { this.metricPoller.stop(); } - renderMetrics(isMetricsHidden: boolean) { + renderMetrics(isMetricsHidden?: boolean) { if (isMetricsHidden) { return null; } @@ -79,7 +84,7 @@ class NonInjectedClusterOverview extends React.Component { ); } - renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) { + renderClusterOverview(isLoaded: boolean, isMetricsHidden?: boolean) { if (!isLoaded) { return ; } @@ -93,7 +98,8 @@ class NonInjectedClusterOverview extends React.Component { } render() { - const isLoaded = nodesStore.isLoaded && eventStore.isLoaded; + const { eventStore, nodeStore } = this.props; + const isLoaded = nodeStore.isLoaded && eventStore.isLoaded; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Cluster); return ( @@ -106,13 +112,19 @@ class NonInjectedClusterOverview extends React.Component { } } -export const ClusterOverview = withInjectables( - NonInjectedClusterOverview, +export const ClusterOverview = withInjectables(NonInjectedClusterOverview, { + getProps: (di) => { + const hostedCluster = di.inject(hostedClusterInjectable); - { - getProps: (di) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + assert(hostedCluster, "Only allowed to renderer ClusterOverview within cluster frame"); + + return { + subscribeStores: di.inject(subscribeStoresInjectable), clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), + hostedCluster, + podStore: di.inject(podStoreInjectable), + eventStore: di.inject(eventStoreInjectable), + nodeStore: di.inject(nodeStoreInjectable), + }; }, -); +}); diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/cluster-pie-charts.tsx index 3121904e08..94612d2229 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/cluster-pie-charts.tsx @@ -11,15 +11,17 @@ import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-over import { MetricNodeRole } from "./cluster-overview-store/cluster-overview-store"; import { Spinner } from "../spinner"; import { Icon } from "../icon"; -import { nodesStore } from "../+nodes/nodes.store"; +import type { NodeStore } from "../+nodes/store"; import type { PieChartData } from "../chart"; import { PieChart } from "../chart"; import { ClusterNoMetrics } from "./cluster-no-metrics"; import { bytesToUnits, cssNames } from "../../utils"; -import { ThemeStore } from "../../theme.store"; +import type { ThemeStore } from "../../themes/store"; import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api"; import { withInjectables } from "@ogre-tools/injectable-react"; import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; +import nodeStoreInjectable from "../+nodes/store.injectable"; +import themeStoreInjectable from "../../themes/store.injectable"; function createLabels(rawLabelData: [string, number | undefined][]): string[] { return rawLabelData.map(([key, value]) => `${key}: ${value?.toFixed(2) || "N/A"}`); @@ -27,9 +29,15 @@ function createLabels(rawLabelData: [string, number | undefined][]): string[] { interface Dependencies { clusterOverviewStore: ClusterOverviewStore; + nodeStore: NodeStore; + themeStore: ThemeStore; } -const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependencies) => { +const NonInjectedClusterPieCharts = observer(({ + clusterOverviewStore, + nodeStore, + themeStore, +}: Dependencies) => { const renderLimitWarning = () => { return (
@@ -46,7 +54,7 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen const { podUsage, podAllocatableCapacity, podCapacity } = data; const cpuLimitsOverload = cpuLimits > cpuAllocatableCapacity; const memoryLimitsOverload = memoryLimits > memoryAllocatableCapacity; - const defaultColor = ThemeStore.getInstance().activeTheme.colors.pieChartDefaultColor; + const defaultColor = themeStore.activeTheme.colors.pieChartDefaultColor; if (!memoryCapacity || !cpuCapacity || !podCapacity || !memoryAllocatableCapacity || !cpuAllocatableCapacity || !podAllocatableCapacity) return null; const cpuData: PieChartData = { @@ -210,7 +218,7 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen }; const renderContent = ({ metricNodeRole, metricsLoaded }: ClusterOverviewStore) => { - const { masterNodes, workerNodes } = nodesStore; + const { masterNodes, workerNodes } = nodeStore; const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; if (!nodes.length) { @@ -249,12 +257,10 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen ); }); -export const ClusterPieCharts = withInjectables( - NonInjectedClusterPieCharts, - - { - getProps: (di) => ({ - clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), - }, -); +export const ClusterPieCharts = withInjectables(NonInjectedClusterPieCharts, { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), + nodeStore: di.inject(nodeStoreInjectable), + themeStore: di.inject(themeStoreInjectable), + }), +}); diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index 714de37b22..c265085489 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -12,51 +12,96 @@ import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { cssNames } from "../../utils"; -import type { IHpaMetric } from "../../../common/k8s-api/endpoints/hpa.api"; -import { HorizontalPodAutoscaler, HpaMetricType } from "../../../common/k8s-api/endpoints/hpa.api"; +import type { HorizontalPodAutoscalerMetricSpec, HorizontalPodAutoscalerMetricTarget } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api"; +import { HorizontalPodAutoscaler, HpaMetricType } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; import { KubeObjectMeta } from "../kube-object-meta"; -import { getDetailsUrl } from "../kube-detail-params"; import logger from "../../../common/logger"; +import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable"; +import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable"; export interface HpaDetailsProps extends KubeObjectDetailsProps { } +interface Dependencies { + apiManager: ApiManager; + getDetailsUrl: GetDetailsUrl; +} + @observer -export class HpaDetails extends React.Component { +class NonInjectedHpaDetails extends React.Component { + private renderTargetLink(target: HorizontalPodAutoscalerMetricTarget | undefined) { + if (!target) { + return null; + } + + const { object: hpa, apiManager, getDetailsUrl } = this.props; + const { kind, name } = target; + const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa)); + + return ( + <> + on + + {`${kind}/${name}`} + + + ); + } + renderMetrics() { const { object: hpa } = this.props; - const renderName = (metric: IHpaMetric) => { + const renderName = (metric: HorizontalPodAutoscalerMetricSpec) => { switch (metric.type) { + case HpaMetricType.ContainerResource: + + // fallthrough case HpaMetricType.Resource: { - const addition = metric.resource.targetAverageUtilization + const metricSpec = metric.resource ?? metric.containerResource; + const addition = metricSpec.targetAverageUtilization ? "(as a percentage of request)" : ""; - return <>Resource {metric.resource.name} on Pods {addition}; - } - case HpaMetricType.Pods: - return <>{metric.pods.metricName} on Pods; - - case HpaMetricType.Object: { - const { target } = metric.object; - const { kind, name } = target; - const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa)); - return ( <> - {metric.object.metricName} on{" "} - {kind}/{name} + Resource + {metricSpec.name} + {" "} + on Pods + {addition} + + ); + } + case HpaMetricType.Pods: + return ( + <> + {metric.pods.metricName} + {" "} + on Pods + + ); + + case HpaMetricType.Object: { + return ( + <> + {metric.object.metricName} + {" "} + {this.renderTargetLink(metric.object.target)} ); } case HpaMetricType.External: return ( <> - {metric.external.metricName} on{" "} - {JSON.stringify(metric.external.selector)} + {metric.external.metricName} + {" "} + on + {" "} + {JSON.stringify(metric.external.metricSelector)} ); } @@ -82,7 +127,7 @@ export class HpaDetails extends React.Component { } render() { - const { object: hpa } = this.props; + const { object: hpa, apiManager, getDetailsUrl } = this.props; if (!hpa) { return null; @@ -103,7 +148,9 @@ export class HpaDetails extends React.Component { {scaleTargetRef && ( - {scaleTargetRef.kind}/{scaleTargetRef.name} + {scaleTargetRef.kind} + / + {scaleTargetRef.name} )} @@ -120,19 +167,20 @@ export class HpaDetails extends React.Component { {hpa.getReplicas()} - - {hpa.getConditions().map(({ type, tooltip, isReady }) => { - if (!isReady) return null; - - return ( + + {hpa.getReadyConditions() + .map(({ type, tooltip, isReady }) => ( - ); - })} + ))} Metrics @@ -143,3 +191,11 @@ export class HpaDetails extends React.Component { ); } } + +export const HpaDetails = withInjectables(NonInjectedHpaDetails, { + getProps: (di, props) => ({ + ...props, + apiManager: di.inject(apiManagerInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + }), +}); diff --git a/src/renderer/components/+config-autoscalers/hpa.store.ts b/src/renderer/components/+config-autoscalers/hpa.store.ts deleted file mode 100644 index d42ff1e4a8..0000000000 --- a/src/renderer/components/+config-autoscalers/hpa.store.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/hpa.api"; -import { hpaApi } from "../../../common/k8s-api/endpoints/hpa.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class HPAStore extends KubeObjectStore { - api = hpaApi; -} - -export const hpaStore = new HPAStore(); -apiManager.registerStore(hpaStore); diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 51866e491e..b8c70541d6 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -8,8 +8,8 @@ import "./hpa.scss"; import React from "react"; import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/hpa.api"; -import { hpaStore } from "./hpa.store"; +import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api"; +import { horizontalPodAutoscalerStore } from "./legacy-store"; import { Badge } from "../badge"; import { cssNames } from "../../utils"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -38,7 +38,13 @@ export class HorizontalPodAutoscalers extends React.Component { const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : ""; - return

{hpa.getMetricValues(metrics[0])} {metricsRemain}

; + return ( +

+ {hpa.getMetricValues(metrics[0])} + {" "} + {metricsRemain} +

+ ); } render() { @@ -48,7 +54,7 @@ export class HorizontalPodAutoscalers extends React.Component { isConfigurable tableId="configuration_hpa" className="HorizontalPodAutoscalers" - store={hpaStore} + store={horizontalPodAutoscalerStore} sortingCallbacks={{ [columnId.name]: hpa => hpa.getName(), [columnId.namespace]: hpa => hpa.getNs(), diff --git a/src/renderer/components/+config-autoscalers/legacy-store.ts b/src/renderer/components/+config-autoscalers/legacy-store.ts new file mode 100644 index 0000000000..87ff91360e --- /dev/null +++ b/src/renderer/components/+config-autoscalers/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import horizontalPodAutoscalerStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(horizontalPodAutoscalerStoreInjectable)` instead + */ +export const horizontalPodAutoscalerStore = asLegacyGlobalForExtensionApi(horizontalPodAutoscalerStoreInjectable); diff --git a/src/renderer/components/+config-autoscalers/store.injectable.ts b/src/renderer/components/+config-autoscalers/store.injectable.ts new file mode 100644 index 0000000000..a2d271df5d --- /dev/null +++ b/src/renderer/components/+config-autoscalers/store.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import horizontalPodAutoscalerApiInjectable from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { HorizontalPodAutoscalerStore } from "./store"; + +const horizontalPodAutoscalerStoreInjectable = getInjectable({ + id: "horizontal-pod-autoscaler-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "horizontalPodAutoscalerStore is only available in certain environments"); + + const api = di.inject(horizontalPodAutoscalerApiInjectable); + + return new HorizontalPodAutoscalerStore(api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default horizontalPodAutoscalerStoreInjectable; diff --git a/src/renderer/components/+config-autoscalers/store.ts b/src/renderer/components/+config-autoscalers/store.ts new file mode 100644 index 0000000000..ccee4ab943 --- /dev/null +++ b/src/renderer/components/+config-autoscalers/store.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { HorizontalPodAutoscaler, HorizontalPodAutoscalerApi } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api"; + +export class HorizontalPodAutoscalerStore extends KubeObjectStore { +} diff --git a/src/renderer/components/+config-limit-ranges/legacy-store.ts b/src/renderer/components/+config-limit-ranges/legacy-store.ts new file mode 100644 index 0000000000..d3227c26c2 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import limitRangeStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(limitRangeStoreInjectable)` instead + */ +export const limitRangeStore = asLegacyGlobalForExtensionApi(limitRangeStoreInjectable); diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx index 1448fc2b1f..313da25667 100644 --- a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx @@ -75,27 +75,27 @@ export class LimitRangeDetails extends React.Component {
- {containerLimits.length > 0 && - - { - renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) - } - - } - {podLimits.length > 0 && - - { - renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) - } - - } - {pvcLimits.length > 0 && - - { - renderLimitDetails(pvcLimits, [Resource.STORAGE]) - } - - } + {containerLimits.length > 0 && ( + + { + renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + )} + {podLimits.length > 0 && ( + + { + renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + )} + {pvcLimits.length > 0 && ( + + { + renderLimitDetails(pvcLimits, [Resource.STORAGE]) + } + + )}
); } diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts deleted file mode 100644 index dd5ef975ff..0000000000 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import type { LimitRange } from "../../../common/k8s-api/endpoints/limit-range.api"; -import { limitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api"; - -export class LimitRangesStore extends KubeObjectStore { - api = limitRangeApi; -} - -export const limitRangeStore = new LimitRangesStore(); -apiManager.registerStore(limitRangeStore); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx index 8bf77cadc1..3d233fe67a 100644 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx @@ -7,7 +7,7 @@ import "./limit-ranges.scss"; import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { limitRangeStore } from "./limit-ranges.store"; +import { limitRangeStore } from "./legacy-store"; import React from "react"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout"; diff --git a/src/renderer/components/+config-limit-ranges/store.injectable.ts b/src/renderer/components/+config-limit-ranges/store.injectable.ts new file mode 100644 index 0000000000..4f1224131a --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/store.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import limitRangeApiInjectable from "../../../common/k8s-api/endpoints/limit-range.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { LimitRangeStore } from "./store"; + +const limitRangeStoreInjectable = getInjectable({ + id: "limit-range-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "limitRangeStore is only available in certain environments"); + + const api = di.inject(limitRangeApiInjectable); + + return new LimitRangeStore(api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default limitRangeStoreInjectable; diff --git a/src/renderer/components/+config-limit-ranges/store.ts b/src/renderer/components/+config-limit-ranges/store.ts new file mode 100644 index 0000000000..b38b0b30b5 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/store.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { LimitRange, LimitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api"; + +export class LimitRangeStore extends KubeObjectStore { +} diff --git a/src/renderer/components/+config-maps/config-map-details.tsx b/src/renderer/components/+config-maps/config-map-details.tsx index 61d2ea4ae1..1b7c88eed4 100644 --- a/src/renderer/components/+config-maps/config-map-details.tsx +++ b/src/renderer/components/+config-maps/config-map-details.tsx @@ -12,7 +12,7 @@ import { DrawerTitle } from "../drawer"; import { Notifications } from "../notifications"; import { Input } from "../input"; import { Button } from "../button"; -import { configMapsStore } from "./config-maps.store"; +import { configMapStore } from "./legacy-store"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { ConfigMap } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; @@ -24,7 +24,7 @@ export interface ConfigMapDetailsProps extends KubeObjectDetailsProps @observer export class ConfigMapDetails extends React.Component { @observable isSaving = false; - @observable data = observable.map(); + @observable data = observable.map(); constructor(props: ConfigMapDetailsProps) { super(props); @@ -48,15 +48,17 @@ export class ConfigMapDetails extends React.Component { try { this.isSaving = true; - await configMapsStore.update(configMap, { + await configMapStore.update(configMap, { ...configMap, data: Object.fromEntries(this.data), }); - Notifications.ok( + Notifications.ok((

- <>ConfigMap {configMap.getName()} successfully updated. -

, - ); + {"ConfigMap "} + {configMap.getName()} + {" successfully updated."} +

+ )); } catch (error) { Notifications.error(`Failed to save config map: ${error}`); } finally { @@ -104,7 +106,8 @@ export class ConfigMapDetails extends React.Component { }
- {this.quotaEntries.map(([quota, value]) => { - return ( -
-
{quota}
-
{value}
- this.quotas[quota] = ""} /> -
- ); - })} + {this.quotaEntries.map(([quota, value]) => ( +
+
{quota}
+
{value}
+ this.quotas[quota] = ""} /> +
+ ))}
diff --git a/src/renderer/components/+config-resource-quotas/legacy-store.ts b/src/renderer/components/+config-resource-quotas/legacy-store.ts new file mode 100644 index 0000000000..2f13765856 --- /dev/null +++ b/src/renderer/components/+config-resource-quotas/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import resourceQuotaStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(resourceQuotaStoreInjectable)` instead + */ +export const resourceQuotaStore = asLegacyGlobalForExtensionApi(resourceQuotaStoreInjectable); diff --git a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx b/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx index 3a1cff2080..2443ea60a3 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx @@ -8,7 +8,7 @@ import React from "react"; import kebabCase from "lodash/kebabCase"; import { observer } from "mobx-react"; import { DrawerItem, DrawerTitle } from "../drawer"; -import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils"; +import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber, object, hasDefinedTupleValue } from "../../utils"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { ResourceQuota } from "../../../common/k8s-api/endpoints/resource-quota.api"; import { LineProgress } from "../line-progress"; @@ -32,25 +32,29 @@ function transformUnit(name: string, value: string): number { } function renderQuotas(quota: ResourceQuota): JSX.Element[] { - const { hard = {}, used = {}} = quota.status; + const { hard = {}, used = {}} = quota.status ?? {}; - return Object.entries(hard) - .filter(([name]) => used[name]) + return object.entries(hard) + .filter(hasDefinedTupleValue) .map(([name, value]) => { - const current = transformUnit(name, used[name]); + const current = transformUnit(name, value); const max = transformUnit(name, value); const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage return (
{name} - {used[name]} / {value} + + {`${used[name]} / ${value}`} + Set: {value}. Usage: {`${usage}%`}

- } + tooltip={( +

+ {`Set: ${value}. Usage: ${usage}%`} +

+ )} />
); diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts b/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts deleted file mode 100644 index ff801c0483..0000000000 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { ResourceQuota } from "../../../common/k8s-api/endpoints/resource-quota.api"; -import { resourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class ResourceQuotasStore extends KubeObjectStore { - api = resourceQuotaApi; -} - -export const resourceQuotaStore = new ResourceQuotasStore(); -apiManager.registerStore(resourceQuotaStore); diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx index 5e602b6f1f..607274ee0f 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx @@ -9,7 +9,7 @@ import React from "react"; import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object-list-layout"; import { AddQuotaDialog } from "./add-quota-dialog"; -import { resourceQuotaStore } from "./resource-quotas.store"; +import { resourceQuotaStore } from "./legacy-store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout"; import { KubeObjectAge } from "../kube-object/age"; diff --git a/src/renderer/components/+config-resource-quotas/store.injectable.ts b/src/renderer/components/+config-resource-quotas/store.injectable.ts new file mode 100644 index 0000000000..d8294c8132 --- /dev/null +++ b/src/renderer/components/+config-resource-quotas/store.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import resourceQuotaApiInjectable from "../../../common/k8s-api/endpoints/resource-quota.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { ResourceQuotaStore } from "./store"; + +const resourceQuotaStoreInjectable = getInjectable({ + id: "resource-quota-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "resourceQuotaStore is only available in certain environments"); + + const api = di.inject(resourceQuotaApiInjectable); + + return new ResourceQuotaStore(api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default resourceQuotaStoreInjectable; diff --git a/src/renderer/components/+config-resource-quotas/store.ts b/src/renderer/components/+config-resource-quotas/store.ts new file mode 100644 index 0000000000..14272c3def --- /dev/null +++ b/src/renderer/components/+config-resource-quotas/store.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { ResourceQuota, ResourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; + +export class ResourceQuotaStore extends KubeObjectStore { +} diff --git a/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx b/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx index df8c1c1cf8..6903e853bc 100644 --- a/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx +++ b/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx @@ -8,7 +8,9 @@ import { render } from "@testing-library/react"; import { SecretDetails } from "../secret-details"; import { Secret, SecretType } from "../../../../common/k8s-api/endpoints"; -jest.mock("../../kube-object-meta/kube-object-meta"); +jest.mock("../../kube-object-meta/kube-object-meta", () => ({ + KubeObjectMeta: () => null, +})); describe("SecretDetails tests", () => { @@ -20,6 +22,8 @@ describe("SecretDetails tests", () => { name: "test", resourceVersion: "1", uid: "uid", + namespace: "default", + selfLink: "/api/v1/secrets/default/test", }, data: { foobar: "", diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx index 45950cce03..2d35a79160 100644 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ b/src/renderer/components/+config-secrets/add-secret-dialog.tsx @@ -13,36 +13,34 @@ import { Dialog } from "../dialog"; import { Wizard, WizardStep } from "../wizard"; import { Input } from "../input"; import { systemName } from "../input/input_validators"; -import type { Secret } from "../../../common/k8s-api/endpoints"; -import { secretsApi, SecretType } from "../../../common/k8s-api/endpoints"; +import { reverseSecretTypeMap, secretApi, SecretType } from "../../../common/k8s-api/endpoints"; import { SubTitle } from "../layout/sub-title"; import { NamespaceSelect } from "../+namespaces/namespace-select"; -import type { SelectOption } from "../select"; import { Select } from "../select"; import { Icon } from "../icon"; -import type { KubeObjectMetadata } from "../../../common/k8s-api/kube-object"; -import { base64 } from "../../utils"; +import { base64, iter } from "../../utils"; import { Notifications } from "../notifications"; import upperFirst from "lodash/upperFirst"; import { showDetails } from "../kube-detail-params"; +import { fromEntries } from "../../../common/utils/objects"; export interface AddSecretDialogProps extends Partial { } -interface ISecretTemplateField { +interface SecretTemplateField { key: string; value?: string; required?: boolean; } -interface ISecretTemplate { - [field: string]: ISecretTemplateField[]; - annotations?: ISecretTemplateField[]; - labels?: ISecretTemplateField[]; - data?: ISecretTemplateField[]; +interface SecretTemplate { + [field: string]: SecretTemplateField[] | undefined; + annotations?: SecretTemplateField[]; + labels?: SecretTemplateField[]; + data?: SecretTemplateField[]; } -type ISecretField = keyof ISecretTemplate; +type ISecretField = keyof SecretTemplate; const dialogState = observable.object({ isOpen: false, @@ -63,7 +61,7 @@ export class AddSecretDialog extends React.Component { dialogState.isOpen = false; } - private secretTemplate: { [p: string]: ISecretTemplate } = { + private secretTemplate: Partial> = { [SecretType.Opaque]: {}, [SecretType.ServiceAccountToken]: { annotations: [ @@ -73,10 +71,6 @@ export class AddSecretDialog extends React.Component { }, }; - get types() { - return Object.keys(this.secretTemplate) as SecretType[]; - } - @observable secret = this.secretTemplate; @observable name = ""; @observable namespace = "default"; @@ -91,61 +85,59 @@ export class AddSecretDialog extends React.Component { AddSecretDialog.close(); }; - private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => { - return fields.reduce((data, field) => { - const { key, value } = field; - - if (key) { - data[key] = processValue ? processValue(value) : value; - } - - return data; - }, {}); + private getDataFromFields = (fields: SecretTemplateField[] = [], processValue: (val: string) => string = (val => val)) => { + return iter.pipeline(fields.values()) + .filterMap(({ key, value }) => ( + value + ? [key, processValue(value)] as const + : undefined + )) + .collect(fromEntries); }; createSecret = async () => { const { name, namespace, type } = this; - const { data = [], labels = [], annotations = [] } = this.secret[type]; - const secret: Partial = { - type, - data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""), - metadata: { - name, - namespace, - annotations: this.getDataFromFields(annotations), - labels: this.getDataFromFields(labels), - } as KubeObjectMetadata, - }; + const { data = [], labels = [], annotations = [] } = this.secret[type] ?? {}; try { - const newSecret = await secretsApi.create({ namespace, name }, secret); + const newSecret = await secretApi.create({ namespace, name }, { + type, + data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""), + metadata: { + name, + namespace, + annotations: this.getDataFromFields(annotations), + labels: this.getDataFromFields(labels), + }, + }); - showDetails(newSecret.selfLink); + showDetails(newSecret?.selfLink); this.close(); } catch (err) { - Notifications.error(err); + Notifications.checkedError(err, "Unknown error occured while creating a Secret"); } }; - addField = (field: ISecretField) => { - const fields = this.secret[this.type][field] || []; + private getFields(field: ISecretField) { + return (this.secret[this.type] ??= {})[field] ??= []; + } - fields.push({ key: "", value: "" }); - this.secret[this.type][field] = fields; + addField = (field: ISecretField) => { + this.getFields(field).push({ key: "", value: "" }); }; removeField = (field: ISecretField, index: number) => { - const fields = this.secret[this.type][field] || []; - - fields.splice(index, 1); + this.getFields(field).splice(index, 1); }; renderFields(field: ISecretField) { - const fields = this.secret[this.type][field] || []; - return ( <> - + { />
- {fields.map((item, index) => { - const { key = "", value = "", required } = item; - - return ( + {this.getFields(field) + .map((item, index) => (
item.key = v} + title={item.key} + tabIndex={item.required ? -1 : 0} + readOnly={item.required} + value={item.key} + onChange={v => item.key = v} /> item.value = v} + value={item.value} + onChange={v => item.value = v} /> this.removeField(field, index)} />
- ); - })} + ))}
); @@ -204,15 +196,21 @@ export class AddSecretDialog extends React.Component { close={this.close} > - +
this.name = v} + value={name} + onChange={v => this.name = v} />
@@ -222,7 +220,7 @@ export class AddSecretDialog extends React.Component { id="secret-namespace-input" themeName="light" value={namespace} - onChange={({ value }) => this.namespace = value} + onChange={option => this.namespace = option?.value ?? "default"} />
@@ -230,8 +228,10 @@ export class AddSecretDialog extends React.Component { { {secrets.map(this.renderSecret)}
- {printerColumns.length > 0 && + {printerColumns.length > 0 && ( <> Additional Printer Columns @@ -133,8 +140,8 @@ export class CRDDetails extends React.Component { }
- } - {validation && + )} + {validation && ( <> Validation { style={{ height: 400 }} /> - } + )}
); } diff --git a/src/renderer/components/+custom-resources/crd-groups-url-param.injectable.ts b/src/renderer/components/+custom-resources/crd-groups-url-param.injectable.ts new file mode 100644 index 0000000000..a5a8d9017f --- /dev/null +++ b/src/renderer/components/+custom-resources/crd-groups-url-param.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import createPageParamInjectable from "../../navigation/create-page-param.injectable"; + +const crdGroupsUrlParamInjectable = getInjectable({ + id: "crd-groups-url-param", + instantiate: (di) => { + const createPageParam = di.inject(createPageParamInjectable); + + return createPageParam({ + name: "groups", + defaultValue: [], + }); + }, +}); + +export default crdGroupsUrlParamInjectable; diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 9b3df2ce14..a4c10e820d 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -6,23 +6,21 @@ import "./crd-list.scss"; import React from "react"; -import { computed, makeObservable } from "mobx"; +import { computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import { Link } from "react-router-dom"; -import { stopPropagation } from "../../utils"; +import { iter, stopPropagation } from "../../utils"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { crdStore } from "./crd.store"; -import type { SelectOption } from "../select"; +import { customResourceDefinitionStore } from "./legacy-store"; import { Select } from "../select"; -import { createPageParam } from "../../navigation"; import { Icon } from "../icon"; import { KubeObjectAge } from "../kube-object/age"; import { TabLayout } from "../layout/tab-layout-2"; - -export const crdGroupsUrlParam = createPageParam({ - name: "groups", - defaultValue: [], -}); +import type { PageParam } from "../../navigation"; +import type { CustomResourceDefinitionStore } from "./definition.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import crdGroupsUrlParamInjectable from "./crd-groups-url-param.injectable"; +import customResourceDefinitionStoreInjectable from "./definition.store.injectable"; enum columnId { kind = "kind", @@ -32,49 +30,67 @@ enum columnId { age = "age", } +interface Dependencies { + crdGroupsUrlParam: PageParam; + customResourceDefinitionStore: CustomResourceDefinitionStore; +} + @observer -export class CustomResourceDefinitions extends React.Component { - constructor(props: {}) { +class NonInjectedCustomResourceDefinitions extends React.Component { + private readonly selectedGroups = observable.set(this.props.crdGroupsUrlParam.get()); + + constructor(props: Dependencies) { super(props); makeObservable(this); } - get selectedGroups(): string[] { - return crdGroupsUrlParam.get(); - } - @computed get items() { - if (this.selectedGroups.length) { - return crdStore.items.filter(item => this.selectedGroups.includes(item.getGroup())); + if (this.selectedGroups.size) { + return this.props.customResourceDefinitionStore.items.filter(item => this.selectedGroups.has(item.getGroup())); } - return crdStore.items; // show all by default + return this.props.customResourceDefinitionStore.items; // show all by default } - toggleSelection(group: string) { - const groups = new Set(crdGroupsUrlParam.get()); + @computed get groupSelectOptions() { + return Object.keys(this.props.customResourceDefinitionStore.groups) + .map(group => ({ + value: group, + label: group, + isSelected: this.selectedGroups.has(group), + })); + } - if (groups.has(group)) { - groups.delete(group); - } else { - groups.add(group); + toggleSelection = (options: readonly ({ value: string })[]) => { + const groups = options.map(({ value }) => value); + + this.selectedGroups.replace(groups); + this.props.crdGroupsUrlParam.set(groups); + }; + + private getPlaceholder() { + if (this.selectedGroups.size === 0) { + return "All groups"; } - crdGroupsUrlParam.set([...groups]); + + const prefix = this.selectedGroups.size === 1 + ? "Group" + : "Groups"; + + return `${prefix}: ${iter.join(this.selectedGroups.values(), ", ")}`; } render() { - const { items, selectedGroups } = this; - return ( already has and is always mounted + store={customResourceDefinitionStore} + // Don't subscribe the `customResourceDefinitionStore` because already has and is always mounted subscribeStores={false} - items={items} + items={this.items} sortingCallbacks={{ [columnId.kind]: crd => crd.getResourceKind(), [columnId.group]: crd => crd.getGroup(), @@ -90,42 +106,38 @@ export class CustomResourceDefinitions extends React.Component { crd => -crd.getCreationTimestamp(), ]} renderHeaderTitle="Custom Resources" - customizeHeader={({ filters, ...headerPlaceholders }) => { - let placeholder = <>All groups; - - if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}; - if (selectedGroups.length >= 2) placeholder = <>Groups: {selectedGroups.join(", ")}; - - return { - // todo: move to global filters - filters: ( - <> - {filters} - ( +
+ + {value} + {isSelected && ( + + )} +
+ )} + /> + + ), + ...headerPlaceholders, + })} renderTableHeader={[ { title: "Resource", className: "kind", sortBy: columnId.kind, id: columnId.kind }, { title: "Group", className: "group", sortBy: columnId.group, id: columnId.group }, @@ -134,7 +146,11 @@ export class CustomResourceDefinitions extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={crd => [ - + {crd.getResourceKind()} , crd.getGroup(), @@ -147,3 +163,11 @@ export class CustomResourceDefinitions extends React.Component { ); } } + +export const CustomResourceDefinitions = withInjectables(NonInjectedCustomResourceDefinitions, { + getProps: (di, props) => ({ + ...props, + crdGroupsUrlParam: di.inject(crdGroupsUrlParamInjectable), + customResourceDefinitionStore: di.inject(customResourceDefinitionStoreInjectable), + }), +}); diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resources/crd-resource-details.tsx index 1c43769e2e..48049682e4 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resources/crd-resource-details.tsx @@ -13,10 +13,10 @@ import { DrawerItem } from "../drawer"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { KubeObjectMeta } from "../kube-object-meta"; import { Input } from "../input"; -import type { AdditionalPrinterColumnsV1 } from "../../../common/k8s-api/endpoints/crd.api"; -import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; +import type { AdditionalPrinterColumnsV1 } from "../../../common/k8s-api/endpoints/custom-resource-definition.api"; +import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/custom-resource-definition.api"; import { convertKubectlJsonPathToNodeJsonPath } from "../../utils/jsonPath"; -import type { KubeObjectMetadata, KubeObjectStatus } from "../../../common/k8s-api/kube-object"; +import type { KubeObjectStatus } from "../../../common/k8s-api/kube-object"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import logger from "../../../common/logger"; import { JSONPath } from "@astronautlabs/jsonpath"; @@ -25,9 +25,17 @@ export interface CustomResourceDetailsProps extends KubeObjectDetailsProps + {value.map((value, index) => ( +
  • + {convertSpecValue(value)} +
  • + ))} + + ); } if (typeof value === "object") { @@ -42,32 +50,45 @@ function convertSpecValue(value: any): any { ); } - return value; + if ( + typeof value === "boolean" + || typeof value === "string" + || typeof value === "number" + ) { + return value.toString(); + } + + return null; } @observer export class CustomResourceDetails extends React.Component { renderAdditionalColumns(resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { return columns.map(({ name, jsonPath }) => ( - + {convertSpecValue(JSONPath.query(resource, convertKubectlJsonPathToNodeJsonPath(jsonPath)))} )); } - renderStatus(customResource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { + renderStatus(customResource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { const showStatus = !columns.find(column => column.name == "Status") && Array.isArray(customResource.status?.conditions); if (!showStatus) { return null; } - const conditions = customResource.status.conditions - .filter(({ type, reason }) => type || reason) - .map(({ type, reason, message, status }) => ({ kind: type || reason, message, status })) + const conditions = customResource.status?.conditions + ?.filter(({ type, reason }) => type || reason) + .map(({ type, reason, message, status }) => ({ + kind: type || reason || "", + message, + status, + })) .map(({ kind, message, status }, index) => ( + {conditions} ); diff --git a/src/renderer/components/+custom-resources/crd-resource.store.ts b/src/renderer/components/+custom-resources/crd-resource.store.ts deleted file mode 100644 index 0ccfad18b4..0000000000 --- a/src/renderer/components/+custom-resources/crd-resource.store.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { KubeApi } from "../../../common/k8s-api/kube-api"; -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; - -export class CRDResourceStore extends KubeObjectStore { - constructor(api: KubeApi) { - super(api); - } -} diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 08d56488bd..7b3d87529f 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -10,13 +10,15 @@ import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object-list-layout"; import type { IComputedValue } from "mobx"; import { computed, makeObservable } from "mobx"; -import { crdStore } from "./crd.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; import { safeJSONPathValue } from "../../utils/jsonPath"; import { TabLayout } from "../layout/tab-layout-2"; import { withInjectables } from "@ogre-tools/injectable-react"; import customResourcesRouteParametersInjectable from "./custom-resources-route-parameters.injectable"; import { KubeObjectAge } from "../kube-object/age"; +import type { CustomResourceDefinitionStore } from "./definition.store"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable"; +import customResourceDefinitionStoreInjectable from "./definition.store.injectable"; enum columnId { name = "name", @@ -27,27 +29,29 @@ enum columnId { interface Dependencies { group: IComputedValue; name: IComputedValue; + apiManager: ApiManager; + customResourceDefinitionStore: CustomResourceDefinitionStore; } @observer -class NonInjectedCrdResources extends React.Component { +class NonInjectedCustomResources extends React.Component { constructor(props: Dependencies) { super(props); makeObservable(this); } @computed get crd() { - return crdStore.getByGroup(this.props.group.get(), this.props.name.get()); + return this.props.customResourceDefinitionStore.getByGroup(this.props.group.get(), this.props.name.get()); } @computed get store() { - return apiManager.getStore(this.crd?.getResourceApiBase()); + return this.props.apiManager.getStore(this.crd?.getResourceApiBase()); } render() { const { crd, store } = this; - if (!crd) { + if (!crd || !store) { return null; } @@ -85,7 +89,9 @@ class NonInjectedCrdResources extends React.Component { })} renderTableHeader={[ { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + isNamespaced + ? { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace } + : undefined, ...extraColumns.map(({ name }) => ({ title: name, className: name.toLowerCase(), @@ -102,9 +108,13 @@ class NonInjectedCrdResources extends React.Component { ]} failedToLoadMessage={( <> -

    Failed to load {crd.getPluralName()}

    +

    + {`Failed to load ${crd.getPluralName()}`} +

    {!version.served && ( -

    Prefered version ({crd.getGroup()}/{version.name}) is not served

    +

    + {`Prefered version (${crd.getGroup()}/${version.name}) is not served`} +

    )} )} @@ -114,18 +124,11 @@ class NonInjectedCrdResources extends React.Component { } } -export const CrdResources = withInjectables( - NonInjectedCrdResources, - - { - getProps: (di) => { - const routeParameters = di.inject(customResourcesRouteParametersInjectable); - - return { - group: routeParameters.group, - name: routeParameters.name, - }; - }, - }, -); +export const CustomResources = withInjectables(NonInjectedCustomResources, { + getProps: (di) => ({ + ...di.inject(customResourcesRouteParametersInjectable), + apiManager: di.inject(apiManagerInjectable), + customResourceDefinitionStore: di.inject(customResourceDefinitionStoreInjectable), + }), +}); diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts deleted file mode 100644 index 90e58aef75..0000000000 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { computed, reaction, makeObservable } from "mobx"; -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { autoBind } from "../../utils"; -import type { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; -import { crdApi } from "../../../common/k8s-api/endpoints/crd.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { KubeApi } from "../../../common/k8s-api/kube-api"; -import { CRDResourceStore } from "./crd-resource.store"; -import { KubeObject } from "../../../common/k8s-api/kube-object"; - -function initStore(crd: CustomResourceDefinition) { - const objectConstructor = class extends KubeObject { - static readonly kind = crd.getResourceKind(); - static readonly namespaced = crd.isNamespaced(); - static readonly apiBase = crd.getResourceApiBase(); - }; - - const api = apiManager.getApi(objectConstructor.apiBase) - ?? new KubeApi({ objectConstructor }); - - if (!apiManager.getStore(api)) { - apiManager.registerStore(new CRDResourceStore(api)); - } -} - -export class CRDStore extends KubeObjectStore { - api = crdApi; - - constructor() { - super(); - - makeObservable(this); - autoBind(this); - - // auto-init stores for crd-s - reaction(() => this.getItems(), items => items.forEach(initStore)); - } - - protected sortItems(items: CustomResourceDefinition[]) { - return super.sortItems(items, [ - crd => crd.getGroup(), - crd => crd.getName(), - ]); - } - - @computed get groups() { - const groups: Record = {}; - - for (const crd of this.items) { - (groups[crd.getGroup()] ??= []).push(crd); - } - - return groups; - } - - getByGroup(group: string, pluralName: string) { - return this.groups[group]?.find(crd => crd.getPluralName() === pluralName); - } - - getByObject(obj: KubeObject) { - if (!obj) return null; - const { kind, apiVersion } = obj; - - return this.items.find(crd => ( - kind === crd.getResourceKind() && apiVersion === `${crd.getGroup()}/${crd.getVersion()}` - )); - } -} - -export const crdStore = new CRDStore(); - -apiManager.registerStore(crdStore); diff --git a/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx b/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx index b6ddf7cf10..973a0756e6 100644 --- a/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx +++ b/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx @@ -3,17 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { noop, some } from "lodash/fp"; +import { noop } from "lodash/fp"; import { computed } from "mobx"; - -import type { - SidebarItemRegistration } from "../layout/sidebar-items.injectable"; -import { - sidebarItemsInjectionToken, -} from "../layout/sidebar-items.injectable"; +import type { SidebarItemRegistration } from "../layout/sidebar-items.injectable"; +import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable"; import { Icon } from "../icon"; import React from "react"; - import crdListRouteInjectable from "../../../common/front-end-routing/routes/cluster/custom-resources/crd-list/crd-list-route.injectable"; import sidebarItemsForDefinitionGroupsInjectable from "./sidebar-items-for-definition-groups.injectable"; import routeIsActiveInjectable from "../../routes/route-is-active.injectable"; @@ -26,10 +21,7 @@ const customResourceSidebarItemsInjectable = getInjectable({ const navigateToCrdList = di.inject(navigateToCrdListInjectable); const crdListRoute = di.inject(crdListRouteInjectable); const crdListRouteIsActive = di.inject(routeIsActiveInjectable, crdListRoute); - - const definitionGroupSidebarItems = di.inject( - sidebarItemsForDefinitionGroupsInjectable, - ); + const definitionGroupSidebarItems = di.inject(sidebarItemsForDefinitionGroupsInjectable); return computed((): SidebarItemRegistration[] => { const definitionsItem = { @@ -42,12 +34,10 @@ const customResourceSidebarItemsInjectable = getInjectable({ orderNumber: 10, }; - const definitionGroupItems = definitionGroupSidebarItems.get(); - - const childrenAndGrandChildren = [ + const childrenAndGrandChildren = computed(() => [ definitionsItem, - ...definitionGroupItems, - ]; + ...definitionGroupSidebarItems.get(), + ]); const parentItem: SidebarItemRegistration = { id: "custom-resources", @@ -55,11 +45,11 @@ const customResourceSidebarItemsInjectable = getInjectable({ title: "Custom Resources", getIcon: () => , onClick: noop, - isVisible: computed(() => some(item => item.isVisible.get(), childrenAndGrandChildren)), + isVisible: computed(() => childrenAndGrandChildren.get().some(item => item.isVisible?.get())), orderNumber: 110, }; - return [parentItem, definitionsItem, ...definitionGroupItems]; + return [parentItem, definitionsItem, ...definitionGroupSidebarItems.get()]; }); }, diff --git a/src/renderer/components/+custom-resources/custom-resources-route-component.injectable.ts b/src/renderer/components/+custom-resources/custom-resources-route-component.injectable.ts index 1df50fe5df..b31c5577d4 100644 --- a/src/renderer/components/+custom-resources/custom-resources-route-component.injectable.ts +++ b/src/renderer/components/+custom-resources/custom-resources-route-component.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { CrdResources } from "./crd-resources"; +import { CustomResources } from "./crd-resources"; import customResourcesRouteInjectable from "../../../common/front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable"; import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token"; @@ -12,7 +12,7 @@ const customResourcesRouteComponentInjectable = getInjectable({ instantiate: (di) => ({ route: di.inject(customResourcesRouteInjectable), - Component: CrdResources, + Component: CustomResources, }), injectionToken: routeSpecificComponentInjectionToken, diff --git a/src/renderer/components/+custom-resources/custom-resources.injectable.ts b/src/renderer/components/+custom-resources/custom-resources.injectable.ts index e1346dcf2b..2a9ee55927 100644 --- a/src/renderer/components/+custom-resources/custom-resources.injectable.ts +++ b/src/renderer/components/+custom-resources/custom-resources.injectable.ts @@ -5,23 +5,26 @@ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import { crdStore } from "./crd.store"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable"; -import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable"; +import customResourceDefinitionStoreInjectable from "./definition.store.injectable"; const customResourceDefinitionsInjectable = getInjectable({ id: "custom-resource-definitions", instantiate: (di) => { - const currentlyInClusterFrame = di.inject(currentlyInClusterFrameInjectable); + const createStoresAndApis = di.inject(storesAndApisCanBeCreatedInjectable); - if (currentlyInClusterFrame) { - const subscribeStores = di.inject(subscribeStoresInjectable); - - subscribeStores([crdStore]); + if (!createStoresAndApis) { + return computed(() => []); } - return computed(() => [...crdStore.items]); + const store = di.inject(customResourceDefinitionStoreInjectable); + const subscribeStores = di.inject(subscribeStoresInjectable); + + subscribeStores([store]); + + return computed(() => [...store.items]); }, }); diff --git a/src/renderer/components/+custom-resources/definition.store.injectable.ts b/src/renderer/components/+custom-resources/definition.store.injectable.ts new file mode 100644 index 0000000000..ee4719cc97 --- /dev/null +++ b/src/renderer/components/+custom-resources/definition.store.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import autoRegistrationEmitterInjectable from "../../../common/k8s-api/api-manager/auto-registration-emitter.injectable"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import customResourceDefinitionApiInjectable from "../../../common/k8s-api/endpoints/custom-resource-definition.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { CustomResourceDefinitionStore } from "./definition.store"; + +const customResourceDefinitionStoreInjectable = getInjectable({ + id: "custom-resource-definition-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "customResourceDefinitionStore is only available in certain environments"); + + const api = di.inject(customResourceDefinitionApiInjectable); + + return new CustomResourceDefinitionStore({ + autoRegistration: di.inject(autoRegistrationEmitterInjectable), + }, api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default customResourceDefinitionStoreInjectable; diff --git a/src/renderer/components/+custom-resources/definition.store.ts b/src/renderer/components/+custom-resources/definition.store.ts new file mode 100644 index 0000000000..6a4b88a70e --- /dev/null +++ b/src/renderer/components/+custom-resources/definition.store.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { computed, reaction, makeObservable } from "mobx"; +import type { KubeObjectStoreOptions } from "../../../common/k8s-api/kube-object.store"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import { autoBind } from "../../utils"; +import type { CustomResourceDefinition, CustomResourceDefinitionApi } from "../../../common/k8s-api/endpoints/custom-resource-definition.api"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; +import type TypedEventEmitter from "typed-emitter"; +import type { LegacyAutoRegistration } from "../../../common/k8s-api/api-manager/auto-registration-emitter.injectable"; + +export interface CustomResourceDefinitionStoreDependencies { + readonly autoRegistration: TypedEventEmitter; +} + +export class CustomResourceDefinitionStore extends KubeObjectStore { + constructor( + protected readonly dependencies: CustomResourceDefinitionStoreDependencies, + api: CustomResourceDefinitionApi, + opts?: KubeObjectStoreOptions, + ) { + super(api, opts); + makeObservable(this); + autoBind(this); + + reaction( + () => this.getItems(), + crds => { + for (const crd of crds) { + this.dependencies.autoRegistration.emit("customResourceDefinition", crd); + } + }, + ); + } + + protected sortItems(items: CustomResourceDefinition[]) { + return super.sortItems(items, [ + crd => crd.getGroup(), + crd => crd.getName(), + ]); + } + + @computed get groups() { + const groups: Record = {}; + + for (const crd of this.items) { + (groups[crd.getGroup()] ??= []).push(crd); + } + + return groups; + } + + getByGroup(group: string, pluralName: string) { + return this.groups[group]?.find(crd => crd.getPluralName() === pluralName); + } + + getByObject(obj: KubeObject) { + if (!obj) return null; + const { kind, apiVersion } = obj; + + return this.items.find(crd => ( + kind === crd.getResourceKind() && apiVersion === `${crd.getGroup()}/${crd.getVersion()}` + )); + } +} diff --git a/src/renderer/components/+custom-resources/legacy-store.ts b/src/renderer/components/+custom-resources/legacy-store.ts new file mode 100644 index 0000000000..6fcce0b2fb --- /dev/null +++ b/src/renderer/components/+custom-resources/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import customResourceDefinitionStoreInjectable from "./definition.store.injectable"; + +/** + * @deprecated use `di.inject(customResourceDefinitionStoreInjectable)` instead + */ +export const customResourceDefinitionStore = asLegacyGlobalForExtensionApi(customResourceDefinitionStoreInjectable); diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index fc23e94a19..366f5758f6 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -9,10 +9,9 @@ import React from "react"; import type { IComputedValue } from "mobx"; import { observable, makeObservable, computed } from "mobx"; import { observer } from "mobx-react"; -import { navigation } from "../../navigation"; import { Tabs, Tab } from "../tabs"; import type { CatalogEntity } from "../../api/catalog-entity"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry"; import { EntitySettingRegistry } from "../../../extensions/registries"; import { groupBy } from "lodash"; import { SettingLayout } from "../layout/setting-layout"; @@ -20,20 +19,25 @@ import logger from "../../../common/logger"; import { Avatar } from "../avatar"; import { withInjectables } from "@ogre-tools/injectable-react"; import entitySettingsRouteParametersInjectable from "./entity-settings-route-parameters.injectable"; +import type { ObservableHistory } from "mobx-observable-history"; +import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable"; +import observableHistoryInjectable from "../../navigation/observable-history.injectable"; interface Dependencies { entityId: IComputedValue; + entityRegistry: CatalogEntityRegistry; + observableHistory: ObservableHistory; } @observer class NonInjectedEntitySettings extends React.Component { - @observable activeTab: string; + @observable activeTab?: string; constructor(props: Dependencies) { super(props); makeObservable(this); - const { hash } = navigation.location; + const { hash } = this.props.observableHistory.location; if (hash) { const menuId = hash.slice(1); @@ -50,8 +54,8 @@ class NonInjectedEntitySettings extends React.Component { return this.props.entityId.get(); } - get entity(): CatalogEntity { - return catalogEntityRegistry.getById(this.entityId); + get entity() { + return this.props.entityRegistry.getById(this.entityId); } get menuItems() { @@ -70,7 +74,7 @@ class NonInjectedEntitySettings extends React.Component { this.activeTab = tabId; }; - renderNavigation() { + renderNavigation(entity: CatalogEntity) { const groups = Object.entries(groupBy(this.menuItems, (item) => item.group || "Extensions")); groups.sort((a, b) => { @@ -84,17 +88,22 @@ class NonInjectedEntitySettings extends React.Component { <>
    - {this.entity.getName()} + {entity.getName()}
    - + { groups.map((group, groupIndex) => (
    @@ -115,17 +124,17 @@ class NonInjectedEntitySettings extends React.Component { } render() { - if (!this.entity) { + const { activeSetting, entity } = this; + + if (!entity) { logger.error("[ENTITY-SETTINGS]: entity not found", this.entityId); return null; } - const { activeSetting } = this; - return ( { @@ -133,7 +142,7 @@ class NonInjectedEntitySettings extends React.Component {

    {activeSetting.title}

    - +
    ) @@ -143,16 +152,10 @@ class NonInjectedEntitySettings extends React.Component { } } -export const EntitySettings = withInjectables( - NonInjectedEntitySettings, - - { - getProps: (di) => { - const routeParameters = di.inject(entitySettingsRouteParametersInjectable); - - return { - entityId: routeParameters.entityId, - }; - }, - }, -); +export const EntitySettings = withInjectables(NonInjectedEntitySettings, { + getProps: (di) => ({ + ...di.inject(entitySettingsRouteParametersInjectable), + entityRegistry: di.inject(catalogEntityRegistryInjectable), + observableHistory: di.inject(observableHistoryInjectable), + }), +}); diff --git a/src/renderer/components/+events/duration-absolute.tsx b/src/renderer/components/+events/duration-absolute.tsx new file mode 100644 index 0000000000..2aa1229d74 --- /dev/null +++ b/src/renderer/components/+events/duration-absolute.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { ReactiveDuration } from "../duration/reactive-duration"; +import { LocaleDate } from "../locale-date"; + +export interface DurationAbsoluteTimestampProps { + timestamp: string | undefined; +} + +export const DurationAbsoluteTimestamp = ({ timestamp }: DurationAbsoluteTimestampProps) => { + if (!timestamp) { + return <>{""}; + } + + return ( + <> + + {" ago "} + ( + + ) + + ); +}; diff --git a/src/renderer/components/+events/event-details.tsx b/src/renderer/components/+events/event-details.tsx index 8ab3e665c1..cf45d7a666 100644 --- a/src/renderer/components/+events/event-details.tsx +++ b/src/renderer/components/+events/event-details.tsx @@ -14,83 +14,95 @@ import type { KubeObjectDetailsProps } from "../kube-object-details"; import { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; import { KubeObjectMeta } from "../kube-object-meta"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { LocaleDate } from "../locale-date"; -import { getDetailsUrl } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; import logger from "../../../common/logger"; -import { ReactiveDuration } from "../duration/reactive-duration"; +import { DurationAbsoluteTimestamp } from "./duration-absolute"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable"; +import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable"; +import { cssNames } from "../../utils"; export interface EventDetailsProps extends KubeObjectDetailsProps { } -@observer -export class EventDetails extends React.Component { - render() { - const { object: event } = this.props; - - if (!event) { - return null; - } - - if (!(event instanceof KubeEvent)) { - logger.error("[EventDetails]: passed object that is not an instanceof KubeEvent", event); - - return null; - } - - const { message, reason, count, type, involvedObject } = event; - const { kind, name, namespace, fieldPath } = involvedObject; - - return ( -
    - - - - {message} - - - {reason} - - - {event.getSource()} - - - - {" ago "} - () - - - - {" ago "} - () - - - {count} - - - {type} - - - Involved object - - - Name - Namespace - Kind - Field Path - - - - - {name} - - - {namespace} - {kind} - {fieldPath} - -
    -
    - ); - } +interface Dependencies { + getDetailsUrl: GetDetailsUrl; + apiManager: ApiManager; } + +const NonInjectedEventDetails = observer(({ + apiManager, + getDetailsUrl, + object: event, + className, +}: Dependencies & EventDetailsProps) => { + if (!event) { + return null; + } + + if (!(event instanceof KubeEvent)) { + logger.error("[EventDetails]: passed object that is not an instanceof KubeEvent", event); + + return null; + } + + const { message, reason, count, type, involvedObject } = event; + const { kind, name, namespace, fieldPath } = involvedObject; + + return ( +
    + + + + {message} + + + {reason} + + + {event.getSource()} + + + + + + + + + {count} + + + {type} + + + Involved object + + + Name + Namespace + Kind + Field Path + + + + + {name} + + + {namespace} + {kind} + {fieldPath} + +
    +
    + ); +}); + +export const EventDetails = withInjectables(NonInjectedEventDetails, { + getProps: (di, props) => ({ + ...props, + apiManager: di.inject(apiManagerInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + }), +}); diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index b81e30fbe2..0a2c713415 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -5,16 +5,15 @@ import "./events.scss"; -import React, { Fragment } from "react"; +import React from "react"; import { computed, observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; import { orderBy } from "lodash"; import { TabLayout } from "../layout/tab-layout-2"; -import type { EventStore } from "./event.store"; -import { eventStore } from "./event.store"; +import type { EventStore } from "./store"; import type { KubeObjectListLayoutProps } from "../kube-object-list-layout"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; +import type { KubeEvent, KubeEventApi, KubeEventData } from "../../../common/k8s-api/endpoints/events.api"; import type { TableSortCallbacks, TableSortParams } from "../table"; import type { HeaderCustomizer } from "../item-object-list"; import { Tooltip } from "../tooltip"; @@ -23,11 +22,13 @@ import type { IClassName } from "../../utils"; import { cssNames, stopPropagation } from "../../utils"; import { Icon } from "../icon"; import { getDetailsUrl } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; import { withInjectables } from "@ogre-tools/injectable-react"; import navigateToEventsInjectable from "../../../common/front-end-routing/routes/cluster/events/navigate-to-events.injectable"; import { KubeObjectAge } from "../kube-object/age"; import { ReactiveDuration } from "../duration/reactive-duration"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable"; +import eventStoreInjectable from "./store.injectable"; enum columnId { message = "message", @@ -40,7 +41,7 @@ enum columnId { lastSeen = "last-seen", } -export interface EventsProps extends Partial> { +export interface EventsProps extends Partial> { className?: IClassName; compact?: boolean; compactLimit?: number; @@ -52,6 +53,8 @@ const defaultProps: Partial = { interface Dependencies { navigateToEvents: () => void; + eventStore: EventStore; + apiManager: ApiManager; } @observer @@ -69,7 +72,7 @@ class NonInjectedEvents extends React.Component { [columnId.object]: event => event.involvedObject.name, [columnId.count]: event => event.count, [columnId.age]: event => -event.getCreationTimestamp(), - [columnId.lastSeen]: event => -new Date(event.lastTimestamp).getTime(), + [columnId.lastSeen]: event => event.lastTimestamp ? -new Date(event.lastTimestamp).getTime() : 0, }; constructor(props: Dependencies & EventsProps) { @@ -77,17 +80,13 @@ class NonInjectedEvents extends React.Component { makeObservable(this); } - get store(): EventStore { - return eventStore; - } - @computed get items(): KubeEvent[] { - const items = this.store.contextItems; + const items = this.props.eventStore.contextItems; const { sortBy, orderBy: order } = this.sorting; // we must sort items before passing to "KubeObjectListLayout -> Table" // to make it work with "compact=true" (proper table sorting actions + initial items) - return orderBy(items, this.sortingCallbacks[sortBy], order as any); + return orderBy(items, this.sortingCallbacks[sortBy], order); } @computed get visibleItems(): KubeEvent[] { @@ -101,8 +100,8 @@ class NonInjectedEvents extends React.Component { } customizeHeader: HeaderCustomizer = ({ info, title, ...headerPlaceholders }) => { - const { compact } = this.props; - const { store, items, visibleItems } = this; + const { compact, eventStore } = this.props; + const { items, visibleItems } = this; const allEventsAreShown = visibleItems.length === items.length; // handle "compact"-mode header @@ -113,35 +112,44 @@ class NonInjectedEvents extends React.Component { return { title, - info: ({visibleItems.length} of {items.length}), + info: ( + + {"("} + {visibleItems.length} + {" of "} + {items.length} + {")"} + + ), }; } return { - info: <> - {info} - - , + info: ( + <> + {info} + + + ), title, ...headerPlaceholders, }; }; render() { - const { store } = this; - const { compact, compactLimit, className, ...layoutProps } = this.props; + const { apiManager, eventStore, compact, compactLimit, className, ...layoutProps } = this.props; const events = ( { return [ type, // type of event: "Normal" or "Warning" { - className: { warning: isWarning }, + className: cssNames({ warning: isWarning }), title: ( - + <> {message} {message} - + ), }, event.getNs(), - - {involvedObject.kind}: {involvedObject.name} + + {`${involvedObject.kind}: ${involvedObject.name}`} , event.getSource(), event.count, @@ -213,13 +225,11 @@ class NonInjectedEvents extends React.Component { } } -export const Events = withInjectables( - NonInjectedEvents, - - { - getProps: (di, props) => ({ - navigateToEvents: di.inject(navigateToEventsInjectable), - ...props, - }), - }, -); +export const Events = withInjectables(NonInjectedEvents, { + getProps: (di, props) => ({ + ...props, + navigateToEvents: di.inject(navigateToEventsInjectable), + apiManager: di.inject(apiManagerInjectable), + eventStore: di.inject(eventStoreInjectable), + }), +}); diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 4bcb164af9..29cb84b856 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -9,22 +9,23 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import { DrawerItem, DrawerTitle } from "../drawer"; -import type { Disposer } from "../../utils"; import { cssNames } from "../../utils"; import { LocaleDate } from "../locale-date"; -import { eventStore } from "./event.store"; +import type { EventStore } from "./store"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; + +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; +import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable"; +import eventStoreInjectable from "./store.injectable"; export interface KubeEventDetailsProps { object: KubeObject; } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer; + subscribeStores: SubscribeStores; + eventStore: EventStore; } @observer @@ -32,13 +33,13 @@ class NonInjectedKubeEventDetails extends React.Component {event.involvedObject.fieldPath}
    - - - + {event.lastTimestamp && ( + + + + )}
    ))}
    @@ -85,16 +88,13 @@ class NonInjectedKubeEventDetails extends React.Component( - NonInjectedKubeEventDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); +export const KubeEventDetails = withInjectables(NonInjectedKubeEventDetails, { + getProps: (di, props) => ({ + ...props, + subscribeStores: di.inject(subscribeStoresInjectable), + eventStore: di.inject(eventStoreInjectable), + }), +}); diff --git a/src/renderer/components/+events/kube-event-icon.tsx b/src/renderer/components/+events/kube-event-icon.tsx index 80cb59d9d6..7b4754fcb1 100644 --- a/src/renderer/components/+events/kube-event-icon.tsx +++ b/src/renderer/components/+events/kube-event-icon.tsx @@ -8,7 +8,7 @@ import "./kube-event-icon.scss"; import React from "react"; import { Icon } from "../icon"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { eventStore } from "./event.store"; +import { eventStore } from "./legacy-store"; import { cssNames } from "../../utils"; import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; import { KubeObjectAge } from "../kube-object/age"; diff --git a/src/renderer/components/+events/legacy-store.ts b/src/renderer/components/+events/legacy-store.ts new file mode 100644 index 0000000000..2a7378e12c --- /dev/null +++ b/src/renderer/components/+events/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import eventStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(eventStoreInjectable)` instead + */ +export const eventStore = asLegacyGlobalForExtensionApi(eventStoreInjectable); diff --git a/src/renderer/components/+events/store.injectable.ts b/src/renderer/components/+events/store.injectable.ts new file mode 100644 index 0000000000..3e8ae1bc99 --- /dev/null +++ b/src/renderer/components/+events/store.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import getPodByIdInjectable from "../+workloads-pods/get-pod-by-id.injectable"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import kubeEventApiInjectable from "../../../common/k8s-api/endpoints/events.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { EventStore } from "./store"; + +const eventStoreInjectable = getInjectable({ + id: "event-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "eventStore is only available in certain environments"); + + const api = di.inject(kubeEventApiInjectable); + + return new EventStore({ + getPodById: di.inject(getPodByIdInjectable), + }, api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default eventStoreInjectable; diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/store.ts similarity index 65% rename from src/renderer/components/+events/event.store.ts rename to src/renderer/components/+events/store.ts index 1c4e835b9f..a639ea80be 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/store.ts @@ -5,22 +5,25 @@ import groupBy from "lodash/groupBy"; import compact from "lodash/compact"; +import type { KubeObjectStoreOptions } from "../../../common/k8s-api/kube-object.store"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { autoBind } from "../../utils"; -import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; -import { eventApi } from "../../../common/k8s-api/endpoints/events.api"; +import type { KubeEvent, KubeEventApi } from "../../../common/k8s-api/endpoints/events.api"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { Pod } from "../../../common/k8s-api/endpoints/pods.api"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import { Pod } from "../../../common/k8s-api/endpoints/pod.api"; +import type { GetPodById } from "../+workloads-pods/get-pod-by-id.injectable"; -export class EventStore extends KubeObjectStore { - api = eventApi; - limit = 1000; - saveLimit = 50000; +export interface EventStoreDependencies { + getPodById: GetPodById; +} - constructor() { - super(); +export class EventStore extends KubeObjectStore { + constructor( + protected readonly dependencies: EventStoreDependencies, + api: KubeEventApi, + opts: KubeObjectStoreOptions = {}, + ) { + super(api, { limit: 1000, ...opts }); autoBind(this); } @@ -52,9 +55,11 @@ export class EventStore extends KubeObjectStore { const { kind, uid } = recent.involvedObject; if (kind == Pod.kind) { // Wipe out running pods - const pod = podsStore.items.find(pod => pod.getId() == uid); + const pod = this.dependencies.getPodById(uid); - if (!pod || (!pod.hasIssues() && pod.spec.priority < 500000)) return undefined; + if (!pod || (!pod.hasIssues() && (pod.spec?.priority ?? 0) < 500000)) { + return undefined; + } } return recent; @@ -67,6 +72,3 @@ export class EventStore extends KubeObjectStore { return this.getWarnings().length; } } - -export const eventStore = new EventStore(); -apiManager.registerStore(eventStore); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index a29e9bb004..66f0830c3d 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -4,10 +4,9 @@ */ import "@testing-library/jest-dom/extend-expect"; -import { fireEvent, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import fse from "fs-extra"; import React from "react"; -import { UserStore } from "../../../../common/user-store"; import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery"; import type { ExtensionLoader } from "../../../../extensions/extension-loader"; import { ConfirmDialog } from "../../confirm-dialog"; @@ -21,10 +20,14 @@ import { renderFor } from "../../test-utils/renderFor"; import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; -import getConfigurationFileModelInjectable - from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable - from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import getConfigurationFileModelInjectable from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import appVersionInjectable from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import assert from "assert"; +import type { InstallFromInput } from "../install-from-input/install-from-input"; +import installFromInputInjectable from "../install-from-input/install-from-input.injectable"; +import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import { observable, when } from "mobx"; mockWindow(); @@ -50,9 +53,11 @@ jest.mock("../../../../common/utils/tar"); describe("Extensions", () => { let extensionLoader: ExtensionLoader; let extensionDiscovery: ExtensionDiscovery; + let installFromInput: jest.MockedFunction; + let extensionInstallationStateStore: ExtensionInstallationStateStore; let render: DiRender; - beforeEach(async () => { + beforeEach(() => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); @@ -65,18 +70,22 @@ describe("Extensions", () => { "some-directory-for-user-data": {}, }); - await di.runSetups(); - render = renderFor(di); + installFromInput = jest.fn(); + + di.override(installFromInputInjectable, () => installFromInput); + extensionLoader = di.inject(extensionLoaderInjectable); extensionDiscovery = di.inject(extensionDiscoveryInjectable); + extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); extensionLoader.addExtension({ id: "extensionId", manifest: { name: "test", version: "1.2.3", + engines: { lens: "^5.5.0" }, }, absolutePath: "/absolute/path", manifestPath: "/symlinked/path/package.json", @@ -86,48 +95,62 @@ describe("Extensions", () => { }); extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve()); - - UserStore.createInstance(); }); afterEach(() => { mockFs.restore(); - UserStore.resetInstance(); }); it("disables uninstall and disable buttons while uninstalling", async () => { extensionDiscovery.isLoaded = true; - const res = render(<>); - const table = res.getByTestId("extensions-table"); - const menuTrigger = table.querySelector("div[role=row]:first-of-type .actions .Icon"); + render(( + <> + + + + )); + const table = await screen.findByTestId("extensions-table"); + const menuTrigger = table.querySelector(".table div[role='rowgroup'] .actions .Icon"); + + assert(menuTrigger); fireEvent.click(menuTrigger); - expect(res.getByText("Disable")).toHaveAttribute("aria-disabled", "false"); - expect(res.getByText("Uninstall")).toHaveAttribute("aria-disabled", "false"); + expect(await screen.findByText("Disable")).toHaveAttribute("aria-disabled", "false"); + expect(await screen.findByText("Uninstall")).toHaveAttribute("aria-disabled", "false"); - fireEvent.click(res.getByText("Uninstall")); + fireEvent.click(await screen.findByText("Uninstall")); // Approve confirm dialog - fireEvent.click(res.getByText("Yes")); + fireEvent.click(await screen.findByText("Yes")); await waitFor(() => { expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); fireEvent.click(menuTrigger); - expect(res.getByText("Disable")).toHaveAttribute("aria-disabled", "true"); - expect(res.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true"); }, { timeout: 30000, }); }); it("disables install button while installing", async () => { - const res = render(); + render(); - (fse.unlink as jest.MockedFunction).mockReturnValue(Promise.resolve() as any); + const resolveInstall = observable.box(false); - fireEvent.change(res.getByPlaceholderText("File path or URL", { + (fse.unlink as jest.MockedFunction).mockReturnValue(Promise.resolve()); + installFromInput.mockImplementation(async (input) => { + expect(input).toBe("https://test.extensionurl/package.tgz"); + + const clear = extensionInstallationStateStore.startPreInstall(); + + await when(() => resolveInstall.get()); + clear(); + }); + + fireEvent.change(await screen.findByPlaceholderText("File path or URL", { exact: false, }), { target: { @@ -135,8 +158,9 @@ describe("Extensions", () => { }, }); - fireEvent.click(res.getByText("Install")); - expect(res.getByText("Install").closest("button")).toBeDisabled(); + fireEvent.click(await screen.findByText("Install")); + expect((await screen.findByText("Install")).closest("button")).toBeDisabled(); + resolveInstall.set(true); }); it("displays spinner while extensions are loading", () => { diff --git a/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx b/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx index 4c1dbf69d1..0af3149e1b 100644 --- a/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx +++ b/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx @@ -10,7 +10,6 @@ import path from "path"; import { SemVer } from "semver"; import URLParse from "url-parse"; import type { InstallRequest } from "./attempt-install/install-request"; -import { reduce } from "lodash"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; import type { Confirm } from "../confirm-dialog/confirm.injectable"; import { getInjectable } from "@ogre-tools/injectable"; @@ -18,6 +17,7 @@ import attemptInstallInjectable from "./attempt-install/attempt-install.injectab import getBaseRegistryUrlInjectable from "./get-base-registry-url/get-base-registry-url.injectable"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import confirmInjectable from "../confirm-dialog/confirm.injectable"; +import { reduce } from "lodash"; export interface ExtensionInfo { name: string; @@ -78,7 +78,11 @@ const attemptInstallByInfo = ({ } else { Notifications.error((

    - The {name} extension does not have a version or tag {version}. + {"The "} + {name} + {" extension does not have a version or tag "} + {version} + .

    )); @@ -91,19 +95,28 @@ const attemptInstallByInfo = ({ // ignore pre-releases for auto picking the version .filter(version => version.prerelease.length === 0); - finalVersion = reduce( - versions, - (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev, - ).format(); + const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev); + + if (!latestVersion) { + console.error("No versions supplied for that extension", { name }); + Notifications.error(`No versions found for ${name}`); + + return disposer(); + } + + finalVersion = latestVersion.format(); } if (requireConfirmation) { const proceed = await confirm({ message: (

    - Are you sure you want to install{" "} + Are you sure you want to install + {" "} - {name}@{finalVersion} + {name} + @ + {finalVersion} ?

    @@ -117,7 +130,8 @@ const attemptInstallByInfo = ({ } } - const url = json.versions[finalVersion].dist.tarball; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const url = json.versions[finalVersion!].dist.tarball; const fileName = path.basename(url); const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx index 0d9d7fc7e8..9d83862357 100644 --- a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx @@ -74,7 +74,9 @@ export const attemptInstall =
    Extension Install Collision:

    - The {name} extension is currently {curState.toLowerCase()}. + {"The "} + {name} + {` extension is currently ${curState.toLowerCase()}.`}

    Will not proceed with this current install request.

    , @@ -92,21 +94,20 @@ export const attemptInstall =

    - Install extension{" "} - - {name}@{version} - - ? + {"Install extension "} + {`${name}@${version}`} + ?

    - Description: {description} + {"Description: "} + {description}

    shell.openPath(extensionFolder)} > - Warning: {name}@{oldVersion} will be removed before - installation. + Warning: + {` ${name}@${oldVersion} will be removed before installation.`}
    , ); diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx index 69ff2a59a2..44b02f6351 100644 --- a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx @@ -75,7 +75,9 @@ export const unpackExtension = Notifications.ok(

    - Extension {displayName} successfully installed! + {"Extension "} + {displayName} + {" successfully installed!"}

    , ); } catch (error) { @@ -87,7 +89,9 @@ export const unpackExtension = ); Notifications.error(

    - Installing extension {displayName} has failed:{" "} + {"Installing extension "} + {displayName} + {" has failed: "} {message}

    , ); diff --git a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx index 86e4244337..ad3432574b 100644 --- a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx +++ b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import type { LensExtensionManifest } from "../../../../../extensions/lens-extension"; -import { listTarEntries, readFileFromTar } from "../../../../../common/utils"; +import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils"; import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery"; import path from "path"; @@ -31,17 +31,21 @@ export const validatePackage = async ( throw new Error(`invalid extension bundle, ${manifestFilename} not found`); } - const manifest = await readFileFromTar({ + const manifest = await readFileFromTar({ tarPath: filePath, filePath: manifestLocation, parseJson: true, }); - if (!manifest.main && !manifest.renderer) { - throw new Error( - `${manifestFilename} must specify "main" and/or "renderer" fields`, - ); + if ( + isObject(manifest) + && ( + hasTypedProperty(manifest, "main", isString) + || hasTypedProperty(manifest, "renderer", isString) + ) + ) { + return manifest as unknown as LensExtensionManifest; } - return manifest; + throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); }; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx b/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx index e48f7c0032..2157d32b21 100644 --- a/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx +++ b/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx @@ -30,7 +30,9 @@ const confirmUninstallExtension = ({ const confirmed = await confirm({ message: (

    - Are you sure you want to uninstall extension {displayName}? + {"Are you sure you want to uninstall extension "} + {displayName} + ?

    ), labelOk: "Yes", diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 6bcc068f5f..4974814a37 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -35,13 +35,14 @@ import installOnDropInjectable from "./install-on-drop/install-on-drop.injectabl import { supportedExtensionFormats } from "./supported-extension-formats"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import type { InstallFromInput } from "./install-from-input/install-from-input"; interface Dependencies { userExtensions: IComputedValue; enableExtension: (id: LensExtensionId) => void; disableExtension: (id: LensExtensionId) => void; confirmUninstallExtension: ConfirmUninstallExtension; - installFromInput: (input: string) => Promise; + installFromInput: InstallFromInput; installFromSelectFileDialog: () => Promise; installOnDrop: (files: File[]) => Promise; extensionInstallationStateStore: ExtensionInstallationStateStore; @@ -73,15 +74,33 @@ class NonInjectedExtensions extends React.Component { return ( - +

    Extensions

    - Add new features via Lens Extensions.{" "} - Check out docs{" "} - and list of available extensions. + {"Add new features via Lens Extensions. Check out the "} + + docs + + {" and list of "} + + available extensions + + .

    diff --git a/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx b/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx index 89606beb5d..c3a4ff8381 100644 --- a/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx +++ b/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx @@ -5,7 +5,7 @@ import React from "react"; import type { ExtensionRegistry } from "../../../../common/user-store/preferences-helpers"; -import { defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../../common/user-store/preferences-helpers"; +import { defaultExtensionRegistryUrl } from "../../../../common/user-store/preferences-helpers"; import { promiseExecFile } from "../../../utils"; import { Notifications } from "../../notifications"; @@ -17,10 +17,10 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) = const extensionRegistryUrl = getRegistryUrlPreference(); switch (extensionRegistryUrl.location) { - case ExtensionRegistryLocation.CUSTOM: + case "custom": return extensionRegistryUrl.customUrl; - case ExtensionRegistryLocation.NPMRC: { + case "npmrc": { try { const filteredEnv = Object.fromEntries( Object.entries(process.env) @@ -30,13 +30,20 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) = return stdout.trim(); } catch (error) { - Notifications.error(

    Failed to get configured registry from .npmrc. Falling back to default registry

    ); + Notifications.error(( +

    + Failed to get configured registry from + .npmrc + . Falling back to default registry. +

    + )); console.warn("[EXTENSIONS]: failed to get configured registry from .npmrc", error); } - // fallthrough } + + // fallthrough default: - case ExtensionRegistryLocation.DEFAULT: + case "default": return defaultExtensionRegistryUrl; } }; diff --git a/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts b/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts index c6d99a51e3..ecbb4f9145 100644 --- a/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts +++ b/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts @@ -2,16 +2,19 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -export function getMessageFromError(error: any): string { + +import { hasTypedProperty, isDefined } from "../../../utils"; + +export function getMessageFromError(error: unknown): string { if (!error || typeof error !== "object") { return "an error has occurred"; } - if (error.message) { - return String(error.message); + if (error instanceof Error) { + return error.message; } - if (error.err) { + if (hasTypedProperty(error, "err", isDefined)) { return String(error.err); } diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx index 90d20ea7a4..ab85221421 100644 --- a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx @@ -14,6 +14,9 @@ import { readFileNotify } from "../read-file-notify/read-file-notify"; import type { InstallRequest } from "../attempt-install/install-request"; import type { ExtensionInfo } from "../attempt-install-by-info.injectable"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import { AsyncInputValidationError } from "../../input/input_validators"; + +export type InstallFromInput = (input: string) => Promise; interface Dependencies { attemptInstall: (request: InstallRequest, disposer?: ExtendableDisposer) => Promise; @@ -21,34 +24,59 @@ interface Dependencies { extensionInstallationStateStore: ExtensionInstallationStateStore; } -export const installFromInput = ({ attemptInstall, attemptInstallByInfo, extensionInstallationStateStore }: Dependencies) => async (input: string) => { - let disposer: ExtendableDisposer | undefined = undefined; +export const installFromInput = ({ + attemptInstall, + attemptInstallByInfo, + extensionInstallationStateStore, +}: Dependencies): InstallFromInput => ( + async (input) => { + let disposer: ExtendableDisposer | undefined = undefined; - try { - // fixme: improve error messages for non-tar-file URLs - if (InputValidators.isUrl.validate(input)) { - // install via url - disposer = extensionInstallationStateStore.startPreInstall(); - const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); - const fileName = path.basename(input); + try { + // fixme: improve error messages for non-tar-file URLs + if (InputValidators.isUrl.validate(input, {})) { + // install via url + disposer = extensionInstallationStateStore.startPreInstall(); + const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); + const fileName = path.basename(input); - await attemptInstall({ fileName, dataP: promise }, disposer); - } else if (InputValidators.isPath.validate(input)) { - // install from system path - const fileName = path.basename(input); + return await attemptInstall({ fileName, dataP: promise }, disposer); + } - await attemptInstall({ fileName, dataP: readFileNotify(input) }); - } else if (InputValidators.isExtensionNameInstall.validate(input)) { - const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)]; + try { + await InputValidators.isPath.validate(input, {}); - await attemptInstallByInfo({ name, version }); + // install from system path + const fileName = path.basename(input); + + return await attemptInstall({ fileName, dataP: readFileNotify(input) }); + } catch (error) { + if (error instanceof AsyncInputValidationError) { + const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input); + + if (extNameCaptures) { + const { name, version } = extNameCaptures; + + return await attemptInstallByInfo({ name, version }); + } + } else { + throw error; + } + } + + throw new Error(`Unknown format of input: ${input}`); + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); + Notifications.error(( +

    + {"Installation has failed: "} + {message} +

    + )); + } finally { + disposer?.(); } - } catch (error) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); - Notifications.error(

    Installation has failed: {message}

    ); - } finally { - disposer?.(); } -}; +); diff --git a/src/renderer/components/+extensions/install.tsx b/src/renderer/components/+extensions/install.tsx index a3fee3064b..52c0bd8906 100644 --- a/src/renderer/components/+extensions/install.tsx +++ b/src/renderer/components/+extensions/install.tsx @@ -9,7 +9,6 @@ import { prevDefault } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { observer } from "mobx-react"; -import type { InputValidator } from "../input"; import { Input, InputValidators } from "../input"; import { SubTitle } from "../layout/sub-title"; import { TooltipPosition } from "../tooltip"; @@ -17,6 +16,7 @@ import type { ExtensionInstallationStateStore } from "../../../extensions/extens import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; +import { inputValidator } from "../input/input_validators"; export interface InstallProps { installPath: string; @@ -36,12 +36,12 @@ const installInputValidators = [ InputValidators.isExtensionNameInstall, ]; -const installInputValidator: InputValidator = { +const installInputValidator = inputValidator({ message: "Invalid URL, absolute path, or extension name", - validate: (value: string) => ( - installInputValidators.some(({ validate }) => validate(value)) + validate: (value: string, props) => ( + installInputValidators.some(({ validate }) => validate(value, props)) ), -}; +}); const NonInjectedInstall: React.FC = ({ installPath, @@ -71,14 +71,14 @@ const NonInjectedInstall: React.FC = ({ value={installPath} onChange={onChange} onSubmit={installFromInput} - iconRight={ + iconRight={( - } + )} />
    @@ -95,7 +95,8 @@ const NonInjectedInstall: React.FC = ({
    - Pro-Tip: you can drag-n-drop tarball-file to this area + Pro-Tip + : you can drag-n-drop tarball-file to this area
    ); diff --git a/src/renderer/components/+extensions/installed-extensions.tsx b/src/renderer/components/+extensions/installed-extensions.tsx index 768253008b..2de88642e2 100644 --- a/src/renderer/components/+extensions/installed-extensions.tsx +++ b/src/renderer/components/+extensions/installed-extensions.tsx @@ -46,12 +46,6 @@ function getStatus(extension: InstalledExtension) { } const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extensionInstallationStateStore, extensions, uninstall, enable, disable }: Dependencies & InstalledExtensionsProps) => { - const filters = [ - (extension: InstalledExtension) => extension.manifest.name, - (extension: InstalledExtension) => getStatus(extension), - (extension: InstalledExtension) => extension.manifest.version, - ]; - const columns = useMemo( () => [ { @@ -87,63 +81,61 @@ const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extension ); const data = useMemo( - () => { - return extensions.map(extension => { - const { id, isEnabled, isCompatible, manifest } = extension; - const { name, description, version } = manifest; - const isUninstalling = extensionInstallationStateStore.isExtensionUninstalling(id); + () => extensions.map(extension => { + const { id, isEnabled, isCompatible, manifest } = extension; + const { name, description, version } = manifest; + const isUninstalling = extensionInstallationStateStore.isExtensionUninstalling(id); - return { - extension: ( -
    -
    -
    {name}
    -
    {description}
    -
    + return { + extension: ( +
    +
    +
    {name}
    +
    {description}
    - ), - version, - status: ( -
    - {getStatus(extension)} -
    - ), - actions: ( - - { isCompatible && ( - <> - {isEnabled ? ( - disable(id)} - > - - Disable - - ) : ( - enable(id)} - > - - Enable - - )} - - )} +
    + ), + version, + status: ( +
    + {getStatus(extension)} +
    + ), + actions: ( + + {isCompatible && ( + <> + {isEnabled ? ( + disable(id)} + > + + Disable + + ) : ( + enable(id)} + > + + Enable + + )} + + )} - uninstall(extension)} - > - - Uninstall - - - ), - }; - }); - }, [extensions, extensionInstallationStateStore.anyUninstalling], + uninstall(extension)} + > + + Uninstall + + + ), + }; + }), [extensions, extensionInstallationStateStore.anyUninstalling], ); if (!extensionDiscovery.isLoaded) { @@ -169,7 +161,11 @@ const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extension columns={columns} data={data} items={extensions} - filters={filters} + filters={[ + (extension) => extension.manifest.name, + (extension) => getStatus(extension), + (extension) => extension.manifest.version, + ]} /> ); diff --git a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx index 3380df97d7..3db43813a2 100644 --- a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx +++ b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx @@ -19,45 +19,61 @@ interface Dependencies { extensionInstallationStateStore: ExtensionInstallationStateStore; } -export const uninstallExtension = - ({ extensionLoader, extensionDiscovery, extensionInstallationStateStore }: Dependencies) => - async (extensionId: LensExtensionId): Promise => { - const { manifest } = extensionLoader.getExtension(extensionId); - const displayName = extensionDisplayName(manifest.name, manifest.version); +export const uninstallExtension = ({ + extensionLoader, + extensionDiscovery, + extensionInstallationStateStore, +}: Dependencies) => ( + async (extensionId: LensExtensionId): Promise => { + const ext = extensionLoader.getExtension(extensionId); - try { - logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); - extensionInstallationStateStore.setUninstalling(extensionId); + if (!ext) { + logger.debug(`[EXTENSIONS]: cannot uninstall ${extensionId}, was not installed`); - await extensionDiscovery.uninstallExtension(extensionId); + return true; + } - // wait for the ExtensionLoader to actually uninstall the extension - await when(() => !extensionLoader.userExtensions.has(extensionId)); + const { manifest } = ext; + const displayName = extensionDisplayName(manifest.name, manifest.version); - Notifications.ok( -

    - Extension {displayName} successfully uninstalled! -

    , - ); + try { + logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); + extensionInstallationStateStore.setUninstalling(extensionId); - return true; - } catch (error) { - const message = getMessageFromError(error); + await extensionDiscovery.uninstallExtension(extensionId); - logger.info( - `[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, - { error }, - ); - Notifications.error( -

    - Uninstalling extension {displayName} has failed:{" "} - {message} -

    , - ); + // wait for the ExtensionLoader to actually uninstall the extension + await when(() => !extensionLoader.userExtensions.has(extensionId)); - return false; - } finally { + Notifications.ok( +

    + {"Extension "} + {displayName} + {" successfully uninstalled!"} +

    , + ); + + return true; + } catch (error) { + const message = getMessageFromError(error); + + logger.info( + `[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, + { error }, + ); + Notifications.error( +

    + {"Uninstalling extension "} + {displayName} + {" has failed: "} + {message} +

    , + ); + + return false; + } finally { // Remove uninstall state on uninstall failure - extensionInstallationStateStore.clearUninstalling(extensionId); - } - }; + extensionInstallationStateStore.clearUninstalling(extensionId); + } + } +); diff --git a/src/renderer/components/+helm-charts/helm-chart-details.tsx b/src/renderer/components/+helm-charts/helm-chart-details.tsx index 182b4a0878..04e000bd1f 100644 --- a/src/renderer/components/+helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+helm-charts/helm-chart-details.tsx @@ -8,19 +8,21 @@ import "./helm-chart-details.scss"; import React, { Component } from "react"; import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import { getChartDetails } from "../../../common/k8s-api/endpoints/helm-charts.api"; -import { observable, makeObservable, reaction } from "mobx"; +import { computed, observable, reaction, runInAction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Drawer, DrawerItem } from "../drawer"; import { autoBind, stopPropagation } from "../../utils"; import { MarkdownViewer } from "../markdown-viewer"; import { Spinner } from "../spinner"; import { Button } from "../button"; -import { Select, type SelectOption } from "../select"; +import { Select } from "../select"; import { Badge } from "../badge"; import { Tooltip, withStyles } from "@material-ui/core"; import { withInjectables } from "@ogre-tools/injectable-react"; import createInstallChartTabInjectable from "../dock/install-chart/create-install-chart-tab.injectable"; import { Notifications } from "../notifications"; +import HelmLogoPlaceholder from "./helm-placeholder.svg"; +import type { SingleValue } from "react-select"; export interface HelmChartDetailsProps { chart: HelmChart; @@ -39,38 +41,46 @@ interface Dependencies { @observer class NonInjectedHelmChartDetails extends Component { - @observable chartVersions: HelmChart[]; - @observable selectedChart?: HelmChart; - @observable readme?: string; + readonly chartVersions = observable.array(); + readonly selectedChart = observable.box(); + readonly readme = observable.box(undefined); + readonly chartVerionOptions = computed(() => ( + this.chartVersions.map(chart => ({ + value: chart, + label: chart.version, + })) + )); - private abortController?: AbortController; + private abortController = new AbortController(); constructor(props: HelmChartDetailsProps & Dependencies) { super(props); - makeObservable(this); autoBind(this); } componentWillUnmount() { - this.abortController?.abort(); + this.abortController.abort(); } componentDidMount() { disposeOnUnmount(this, [ reaction(() => this.props.chart, async ({ name, repo, version }) => { - try { - this.selectedChart = undefined; - this.chartVersions = undefined; - this.readme = undefined; + runInAction(() => { + this.selectedChart.set(undefined); + this.chartVersions.clear(); + this.readme.set(""); + }); + try { const { readme, versions } = await getChartDetails(repo, name, { version }); - this.readme = readme; - this.chartVersions = versions; - this.selectedChart = versions[0]; + runInAction(() => { + this.readme.set(readme); + this.chartVersions.replace(versions); + this.selectedChart.set(versions[0]); + }); } catch (error) { - Notifications.error(error); - this.selectedChart = null; + Notifications.checkedError(error, "Unknown error occured while getting chart details"); } }, { fireImmediately: true, @@ -78,72 +88,92 @@ class NonInjectedHelmChartDetails extends Component) { - this.selectedChart = chart; - this.readme = null; + async onVersionChange(option: SingleValue<{ value: HelmChart }>) { + const chart = option?.value ?? this.chartVersions[0]; + + runInAction(() => { + this.selectedChart.set(chart ?? undefined); + this.readme.set(undefined); + }); try { - this.abortController?.abort(); + this.abortController.abort(); this.abortController = new AbortController(); const { chart: { name, repo }} = this.props; const { readme } = await getChartDetails(repo, name, { version: chart.version, reqInit: { signal: this.abortController.signal }}); - this.readme = readme; + this.readme.set(readme); } catch (error) { - Notifications.error(error); + Notifications.checkedError(error, "Unknown error occured while getting chart details"); } } - install() { - this.props.createInstallChartTab(this.selectedChart); + install(selectedChart: HelmChart) { + this.props.createInstallChartTab(selectedChart); this.props.hideDetails(); } - renderIntroduction() { - const { selectedChart, chartVersions, onVersionChange } = this; - const placeholder = require("./helm-placeholder.svg"); - + renderIntroduction(selectedChart: HelmChart) { return (
    event.currentTarget.src = placeholder} + src={selectedChart.getIcon() || HelmLogoPlaceholder} + onError={(event) => event.currentTarget.src = HelmLogoPlaceholder} />
    {selectedChart.getDescription()} -
    - + this.revision.set(option?.value)} + /> +
    + ); + } + + renderContent(release: HelmRelease) { + return ( + + Rollback + {release.getName()} + + )} + done={this.close} + > + this.rollback(release)} + loading={this.isLoading.get()} + > + {this.renderRevisionContent()} + + + ); + } + + render() { + const { state, ...dialogProps } = this.props; + const release = state.get(); + + return ( + this.onOpen(release)) : undefined} + close={this.close} + > + {release && this.renderContent(release)} + + ); + } +} + +export const ReleaseRollbackDialog = withInjectables( + NonInjectedReleaseRollbackDialog, + + { + getProps: (di, props) => ({ + rollbackRelease: di.inject(rollbackReleaseInjectable), + state: di.inject(releaseRollbackDialogStateInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/+helm-releases/dialog/open.injectable.ts b/src/renderer/components/+helm-releases/dialog/open.injectable.ts new file mode 100644 index 0000000000..da1ec2106d --- /dev/null +++ b/src/renderer/components/+helm-releases/dialog/open.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import releaseRollbackDialogStateInjectable from "./state.injectable"; + +export type OpenHelmReleaseRollbackDialog = (release: HelmRelease) => void; + +const openHelmReleaseRollbackDialogInjectable = getInjectable({ + id: "open-helm-release-dialog", + instantiate: (di): OpenHelmReleaseRollbackDialog => { + const state = di.inject(releaseRollbackDialogStateInjectable); + + return release => state.set(release); + }, +}); + +export default openHelmReleaseRollbackDialogInjectable; diff --git a/src/renderer/components/+helm-releases/dialog/state.injectable.ts b/src/renderer/components/+helm-releases/dialog/state.injectable.ts new file mode 100644 index 0000000000..4551eab915 --- /dev/null +++ b/src/renderer/components/+helm-releases/dialog/state.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; + +const releaseRollbackDialogStateInjectable = getInjectable({ + id: "release-rollback-dialog-state", + instantiate: () => observable.box(undefined), +}); + +export default releaseRollbackDialogStateInjectable; diff --git a/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts b/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts index 096259a969..f788d5ac57 100644 --- a/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts +++ b/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts @@ -6,16 +6,19 @@ import { getInjectable } from "@ogre-tools/injectable"; import { getRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import { asyncComputed } from "@ogre-tools/injectable-react"; import releaseInjectable from "./release.injectable"; +import { waitUntilDefined } from "../../../utils"; const releaseDetailsInjectable = getInjectable({ id: "release-details", - instantiate: (di) => - asyncComputed(async () => { - const release = di.inject(releaseInjectable).get(); + instantiate: (di) => { + const releaseComputed = di.inject(releaseInjectable); - return await getRelease(release.name, release.namespace); - }), + return asyncComputed(async () => { + const release = await waitUntilDefined(releaseComputed); + + return getRelease(release.name, release.namespace); + });}, }); export default releaseDetailsInjectable; diff --git a/src/renderer/components/+helm-releases/release-details/release-details.scss b/src/renderer/components/+helm-releases/release-details/release-details.scss index 0a4acfcca3..780d3df401 100644 --- a/src/renderer/components/+helm-releases/release-details/release-details.scss +++ b/src/renderer/components/+helm-releases/release-details/release-details.scss @@ -66,7 +66,7 @@ .notes { white-space: pre-line; - font-family: "RobotoMono", monospace; + font-family: var(--font-monospace); font-size: small; } diff --git a/src/renderer/components/+helm-releases/release-details/release-details.tsx b/src/renderer/components/+helm-releases/release-details/release-details.tsx index c43798cf60..ee01070fbf 100644 --- a/src/renderer/components/+helm-releases/release-details/release-details.tsx +++ b/src/renderer/components/+helm-releases/release-details/release-details.tsx @@ -11,7 +11,7 @@ import type { IComputedValue } from "mobx"; import { computed, makeObservable, observable } from "mobx"; import { Link } from "react-router-dom"; import kebabCase from "lodash/kebabCase"; -import type { HelmRelease, IReleaseDetails, IReleaseUpdateDetails, IReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import type { HelmRelease, HelmReleaseDetails, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import { HelmReleaseMenu } from "../release-menu"; import { Drawer, DrawerItem, DrawerTitle } from "../../drawer"; import { Badge } from "../../badge"; @@ -21,10 +21,9 @@ import { Spinner } from "../../spinner"; import { Table, TableCell, TableHead, TableRow } from "../../table"; import { Button } from "../../button"; import { Notifications } from "../../notifications"; -import { ThemeStore } from "../../../theme.store"; -import { apiManager } from "../../../../common/k8s-api/api-manager"; +import type { ThemeStore } from "../../../themes/store"; +import type { ApiManager } from "../../../../common/k8s-api/api-manager"; import { SubTitle } from "../../layout/sub-title"; -import { getDetailsUrl } from "../../kube-detail-params"; import { Checkbox } from "../../checkbox"; import { MonacoEditor } from "../../monaco-editor"; import type { IAsyncComputed } from "@ogre-tools/injectable-react"; @@ -35,48 +34,52 @@ import releaseInjectable from "./release.injectable"; import releaseDetailsInjectable from "./release-details.injectable"; import releaseValuesInjectable from "./release-values.injectable"; import userSuppliedValuesAreShownInjectable from "./user-supplied-values-are-shown.injectable"; -import type { KubeObject } from "../../../../common/k8s-api/kube-object"; import { KubeObjectAge } from "../../kube-object/age"; +import type { KubeJsonApiData } from "../../../../common/k8s-api/kube-json-api"; +import { entries } from "../../../../common/utils/objects"; +import themeStoreInjectable from "../../../themes/store.injectable"; +import type { GetDetailsUrl } from "../../kube-detail-params/get-details-url.injectable"; +import apiManagerInjectable from "../../../../common/k8s-api/api-manager/manager.injectable"; +import getDetailsUrlInjectable from "../../kube-detail-params/get-details-url.injectable"; export interface ReleaseDetailsProps { hideDetails(): void; } interface Dependencies { - release: IComputedValue; - releaseDetails: IAsyncComputed; + release: IComputedValue; + releaseDetails: IAsyncComputed; releaseValues: IAsyncComputed; - updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise; + updateRelease: (name: string, namespace: string, payload: HelmReleaseUpdatePayload) => Promise; createUpgradeChartTab: (release: HelmRelease) => void; userSuppliedValuesAreShown: { toggle: () => void; value: boolean }; + themeStore: ThemeStore; + apiManager: ApiManager; + getDetailsUrl: GetDetailsUrl; } @observer class NonInjectedReleaseDetails extends Component { @observable saving = false; - private nonSavedValues: string; + private nonSavedValues = ""; constructor(props: ReleaseDetailsProps & Dependencies) { super(props); makeObservable(this); } - @computed get release() { - return this.props.release.get(); - } - @computed get details() { return this.props.releaseDetails.value.get(); } - updateValues = async () => { - const name = this.release.getName(); - const namespace = this.release.getNs(); + updateValues = async (release: HelmRelease) => { + const name = release.getName(); + const namespace = release.getNs(); const data = { - chart: this.release.getChart(), - repo: await this.release.getRepo(), - version: this.release.getVersion(), + chart: release.getChart(), + repo: await release.getRepo(), + version: release.getVersion(), values: this.nonSavedValues, }; @@ -85,24 +88,28 @@ class NonInjectedReleaseDetails extends ComponentRelease {name} successfully updated!

    , +

    + Release + {name} + {" successfully updated!"} +

    , ); this.props.releaseValues.invalidate(); } catch (err) { - Notifications.error(err); + Notifications.checkedError(err, "Unknown error occured while updating release"); } this.saving = false; }; - upgradeVersion = () => { - const { hideDetails } = this.props; + upgradeVersion = (release: HelmRelease) => { + const { hideDetails, createUpgradeChartTab } = this.props; - this.props.createUpgradeChartTab(this.release); + createUpgradeChartTab(release); hideDetails(); }; - renderValues() { + renderValues(release: HelmRelease) { return ( {() => { @@ -133,7 +140,7 @@ class NonInjectedReleaseDetails extends Component this.updateValues(release)} />
    @@ -154,28 +161,29 @@ class NonInjectedReleaseDetails extends Component { - Object.entries(groupBy(resources, item => item.kind)) + entries(groupBy(resources, item => item.kind)) .map(([kind, items]) => ( Name - {items[0].getNs() && Namespace} + {items[0].metadata.namespace && Namespace} Age {items.map(item => { - const name = item.getName(); - const namespace = item.getNs(); + const { name, namespace, uid } = item.metadata; const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion); const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : ""; return ( - + {detailsUrl ? {name} : name} @@ -198,9 +206,7 @@ class NonInjectedReleaseDetails extends Component; } @@ -211,35 +217,40 @@ class NonInjectedReleaseDetails extends Component
    - {this.release.getChart()} + {release.getChart()}
    - {this.release.getUpdated()} ago ({this.release.updated}) + {release.getUpdated()} + {` ago (${release.updated})`} - {this.release.getNs()} + {release.getNs()}
    - {this.release.getVersion()} + {release.getVersion()}
    - + - {this.renderValues()} + {this.renderValues(release)} Notes {this.renderNotes()} Resources @@ -249,39 +260,41 @@ class NonInjectedReleaseDetails extends Component; + const { hideDetails, themeStore } = this.props; + const release = this.props.release.get(); return ( + )} > - {this.renderContent()} + {release && this.renderContent(release)} ); } } -export const ReleaseDetails = withInjectables( - NonInjectedReleaseDetails, - - { - getProps: (di, props) => ({ - release: di.inject(releaseInjectable), - releaseDetails: di.inject(releaseDetailsInjectable), - releaseValues: di.inject(releaseValuesInjectable), - - userSuppliedValuesAreShown: di.inject(userSuppliedValuesAreShownInjectable), - - updateRelease: di.inject(updateReleaseInjectable), - createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), - ...props, - }), - }, -); +export const ReleaseDetails = withInjectables(NonInjectedReleaseDetails, { + getProps: (di, props) => ({ + ...props, + release: di.inject(releaseInjectable), + releaseDetails: di.inject(releaseDetailsInjectable), + releaseValues: di.inject(releaseValuesInjectable), + userSuppliedValuesAreShown: di.inject(userSuppliedValuesAreShownInjectable), + updateRelease: di.inject(updateReleaseInjectable), + createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), + themeStore: di.inject(themeStoreInjectable), + apiManager: di.inject(apiManagerInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + }), +}); diff --git a/src/renderer/components/+helm-releases/release-menu.tsx b/src/renderer/components/+helm-releases/release-menu.tsx index 473c3fb20d..511b104600 100644 --- a/src/renderer/components/+helm-releases/release-menu.tsx +++ b/src/renderer/components/+helm-releases/release-menu.tsx @@ -12,8 +12,9 @@ import { MenuItem } from "../menu"; import { Icon } from "../icon"; import { withInjectables } from "@ogre-tools/injectable-react"; import createUpgradeChartTabInjectable from "../dock/upgrade-chart/create-upgrade-chart-tab.injectable"; -import releaseRollbackDialogModelInjectable from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; import deleteReleaseInjectable from "./delete-release/delete-release.injectable"; +import type { OpenHelmReleaseRollbackDialog } from "./dialog/open.injectable"; +import openHelmReleaseRollbackDialogInjectable from "./dialog/open.injectable"; export interface HelmReleaseMenuProps extends MenuActionsProps { release: HelmRelease; @@ -23,7 +24,7 @@ export interface HelmReleaseMenuProps extends MenuActionsProps { interface Dependencies { deleteRelease: (release: HelmRelease) => Promise; createUpgradeChartTab: (release: HelmRelease) => void; - openRollbackDialog: (release: HelmRelease) => void; + openRollbackDialog: OpenHelmReleaseRollbackDialog; } class NonInjectedHelmReleaseMenu extends React.Component { @@ -52,12 +53,20 @@ class NonInjectedHelmReleaseMenu extends React.Component {hasRollback && ( - + Rollback )} - + Upgrade @@ -72,7 +81,13 @@ class NonInjectedHelmReleaseMenu extends React.Component

    Remove Helm Release {release.name}?

    } + removeConfirmationMessage={() => ( +

    + Remove Helm Release + {release.name} + ? +

    + )} > {this.renderContent()} @@ -80,16 +95,11 @@ class NonInjectedHelmReleaseMenu extends React.Component( - NonInjectedHelmReleaseMenu, - - { - getProps: (di, props) => ({ - deleteRelease: di.inject(deleteReleaseInjectable), - createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), - openRollbackDialog: di.inject(releaseRollbackDialogModelInjectable).open, - - ...props, - }), - }, -); +export const HelmReleaseMenu = withInjectables(NonInjectedHelmReleaseMenu, { + getProps: (di, props) => ({ + ...props, + deleteRelease: di.inject(deleteReleaseInjectable), + createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), + openRollbackDialog: di.inject(openHelmReleaseRollbackDialogInjectable), + }), +}); diff --git a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts b/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts deleted file mode 100644 index b81bdf438b..0000000000 --- a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model"; - -const releaseRollbackDialogModelInjectable = getInjectable({ - id: "release-rollback-dialog-model", - instantiate: () => new ReleaseRollbackDialogModel(), -}); - -export default releaseRollbackDialogModelInjectable; diff --git a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts b/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts deleted file mode 100644 index 21ec78534a..0000000000 --- a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { computed, observable, makeObservable, action } from "mobx"; -import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; - -export class ReleaseRollbackDialogModel { - release: HelmRelease | null = null; - - constructor() { - makeObservable(this, { - isOpen: computed, - release: observable, - open: action, - close: action, - }); - } - - get isOpen() { - return !!this.release; - } - - open = (release: HelmRelease) => { - this.release = release; - }; - - close = () => { - this.release = null; - }; -} diff --git a/src/renderer/components/+helm-releases/release-rollback-dialog.tsx b/src/renderer/components/+helm-releases/release-rollback-dialog.tsx deleted file mode 100644 index e7f9868c75..0000000000 --- a/src/renderer/components/+helm-releases/release-rollback-dialog.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./release-rollback-dialog.scss"; - -import React from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import type { DialogProps } from "../dialog"; -import { Dialog } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { getReleaseHistory, type IReleaseRevision } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import type { SelectOption } from "../select"; -import { Select } from "../select"; -import { Notifications } from "../notifications"; -import orderBy from "lodash/orderBy"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import releaseRollbackDialogModelInjectable - from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; -import type { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model/release-rollback-dialog-model"; -import rollbackReleaseInjectable from "./rollback-release/rollback-release.injectable"; - -export interface ReleaseRollbackDialogProps extends DialogProps { -} - -interface Dependencies { - rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise; - model: ReleaseRollbackDialogModel; -} - -@observer -class NonInjectedReleaseRollbackDialog extends React.Component { - @observable isLoading = false; - @observable revision: IReleaseRevision; - @observable revisions = observable.array(); - - constructor(props: ReleaseRollbackDialogProps & Dependencies) { - super(props); - makeObservable(this); - } - - get release(): HelmRelease { - return this.props.model.release; - } - - onOpen = async () => { - this.isLoading = true; - let releases = await getReleaseHistory(this.release.getName(), this.release.getNs()); - - releases = orderBy(releases, "revision", "desc"); // sort - this.revisions.replace(releases); - this.revision = this.revisions[0]; - this.isLoading = false; - }; - - rollback = async () => { - const revisionNumber = this.revision.revision; - - try { - await this.props.rollbackRelease(this.release.getName(), this.release.getNs(), revisionNumber); - this.props.model.close(); - } catch (err) { - Notifications.error(err); - } - }; - - renderContent() { - const { revision, revisions } = this; - - if (!revision) { - return

    No revisions to rollback.

    ; - } - - return ( -
    - Revision - this.namespace = v.toLowerCase()} + /> + + + + ); + } +} + +export const AddNamespaceDialog = withInjectables(NonInjectedAddNamespaceDialog, { + getProps: (di, props) => ({ + ...props, + namespaceStore: di.inject(namespaceStoreInjectable), + state: di.inject(addNamespaceDialogStateInjectable), + }), +}); diff --git a/src/renderer/components/+namespaces/add-dialog/open.injectable.ts b/src/renderer/components/+namespaces/add-dialog/open.injectable.ts new file mode 100644 index 0000000000..7c88cd6660 --- /dev/null +++ b/src/renderer/components/+namespaces/add-dialog/open.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { action } from "mobx"; +import addNamespaceDialogStateInjectable from "./state.injectable"; + +const openAddNamepaceDialogInjectable = getInjectable({ + id: "open-add-namepace-dialog", + instantiate: (di) => { + const state = di.inject(addNamespaceDialogStateInjectable); + + return action(() => { + state.set(true); + }); + }, +}); + +export default openAddNamepaceDialogInjectable; diff --git a/src/renderer/components/+namespaces/add-dialog/state.injectable.ts b/src/renderer/components/+namespaces/add-dialog/state.injectable.ts new file mode 100644 index 0000000000..043b7db3bb --- /dev/null +++ b/src/renderer/components/+namespaces/add-dialog/state.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const addNamespaceDialogStateInjectable = getInjectable({ + id: "add-namespace-dialog-state", + instantiate: () => observable.box(false), +}); + +export default addNamespaceDialogStateInjectable; diff --git a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts deleted file mode 100644 index 844c3912c0..0000000000 --- a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { AddNamespaceDialogModel } from "./add-namespace-dialog-model"; - -const addNamespaceDialogModelInjectable = getInjectable({ - id: "add-namespace-dialog-model", - instantiate: () => new AddNamespaceDialogModel(), -}); - -export default addNamespaceDialogModelInjectable; diff --git a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts deleted file mode 100644 index 439a9b8ef9..0000000000 --- a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { observable, makeObservable, action } from "mobx"; - -export class AddNamespaceDialogModel { - isOpen = false; - - constructor() { - makeObservable(this, { - isOpen: observable, - open: action, - close: action, - }); - } - - open = () => { - this.isOpen = true; - }; - - close = () => { - this.isOpen = false; - }; -} diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx deleted file mode 100644 index 3307769bad..0000000000 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./add-namespace-dialog.scss"; - -import React from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import type { DialogProps } from "../dialog"; -import { Dialog } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import type { Namespace } from "../../../common/k8s-api/endpoints"; -import { Input } from "../input"; -import { systemName } from "../input/input_validators"; -import { Notifications } from "../notifications"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; -import type { AddNamespaceDialogModel } from "./add-namespace-dialog-model/add-namespace-dialog-model"; -import addNamespaceDialogModelInjectable - from "./add-namespace-dialog-model/add-namespace-dialog-model.injectable"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; - -export interface AddNamespaceDialogProps extends DialogProps { - onSuccess?(ns: Namespace): void; - onError?(error: any): void; -} - -interface Dependencies { - namespaceStore: NamespaceStore; - model: AddNamespaceDialogModel; -} - -@observer -class NonInjectedAddNamespaceDialog extends React.Component { - @observable namespace = ""; - - constructor(props: AddNamespaceDialogProps & Dependencies) { - super(props); - makeObservable(this); - } - - reset = () => { - this.namespace = ""; - }; - - addNamespace = async () => { - const { namespace } = this; - const { onSuccess, onError } = this.props; - - try { - const created = await this.props.namespaceStore.create({ name: namespace }); - - onSuccess?.(created); - this.props.model.close(); - } catch (err) { - Notifications.error(err); - onError?.(err); - } - }; - - render() { - const { model, namespaceStore, ...dialogProps } = this.props; - const { namespace } = this; - const header =
    Create Namespace
    ; - - return ( - - - - this.namespace = v.toLowerCase()} - /> - - - - ); - } -} - -export const AddNamespaceDialog = withInjectables( - NonInjectedAddNamespaceDialog, - - { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - model: di.inject(addNamespaceDialogModelInjectable), - - ...props, - }), - }, -); diff --git a/src/renderer/components/+namespaces/index.ts b/src/renderer/components/+namespaces/index.ts index 1ce2e35264..5542f4ebdb 100644 --- a/src/renderer/components/+namespaces/index.ts +++ b/src/renderer/components/+namespaces/index.ts @@ -4,4 +4,4 @@ */ export * from "./namespace-details"; -export * from "./add-namespace-dialog"; +export * from "./add-dialog/dialog"; diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index 4d94e8990d..12bb7a4208 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -9,37 +9,43 @@ import React from "react"; import { computed, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem } from "../drawer"; -import type { Disposer } from "../../utils"; -import { cssNames } from "../../utils"; -import { getMetricsForNamespace, type IPodMetrics, Namespace } from "../../../common/k8s-api/endpoints"; +import { cssNames } from "../../utils"; +import { getMetricsForNamespace, type PodMetricData, Namespace } from "../../../common/k8s-api/endpoints"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { Link } from "react-router-dom"; import { Spinner } from "../spinner"; -import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; import { KubeObjectMeta } from "../kube-object-meta"; -import { limitRangeStore } from "../+config-limit-ranges/limit-ranges.store"; import { ResourceMetrics } from "../resource-metrics"; import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { getDetailsUrl } from "../kube-detail-params"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; + +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; +import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable"; +import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable"; +import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable"; +import type { ResourceQuotaStore } from "../+config-resource-quotas/store"; +import type { LimitRangeStore } from "../+config-limit-ranges/store"; +import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable"; +import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable"; +import limitRangeStoreInjectable from "../+config-limit-ranges/store.injectable"; +import resourceQuotaStoreInjectable from "../+config-resource-quotas/store.injectable"; export interface NamespaceDetailsProps extends KubeObjectDetailsProps { } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer; + subscribeStores: SubscribeStores; + getActiveClusterEntity: GetActiveClusterEntity; + getDetailsUrl: GetDetailsUrl; + resourceQuotaStore: ResourceQuotaStore; + limitRangeStore: LimitRangeStore; } @observer class NonInjectedNamespaceDetails extends React.Component { - @observable metrics: IPodMetrics = null; + @observable metrics: PodMetricData | null = null; constructor(props: NamespaceDetailsProps & Dependencies) { super(props); @@ -53,8 +59,8 @@ class NonInjectedNamespaceDetails extends React.Component { @@ -76,7 +82,7 @@ class NonInjectedNamespaceDetails extends React.Component @@ -109,37 +117,33 @@ class NonInjectedNamespaceDetails extends React.Component {!this.quotas && resourceQuotaStore.isLoading && } - {this.quotas.map(quota => { - return ( - - {quota.getName()} - - ); - })} + {this.quotas.map(quota => quota.selfLink && ( + + {quota.getName()} + + ))} {!this.limitranges && limitRangeStore.isLoading && } - {this.limitranges.map(limitrange => { - return ( - - {limitrange.getName()} - - ); - })} + {this.limitranges.map(limitrange => limitrange.selfLink && ( + + {limitrange.getName()} + + ))}
    ); } } -export const NamespaceDetails = withInjectables( - NonInjectedNamespaceDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); +export const NamespaceDetails = withInjectables(NonInjectedNamespaceDetails, { + getProps: (di, props) => ({ + ...props, + subscribeStores: di.inject(subscribeStoresInjectable), + getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + limitRangeStore: di.inject(limitRangeStoreInjectable), + resourceQuotaStore: di.inject(resourceQuotaStoreInjectable), + }), +}); diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts index 571029c853..087ff4a29b 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts @@ -4,7 +4,7 @@ */ import { NamespaceSelectFilterModel } from "./namespace-select-filter-model"; import { getInjectable } from "@ogre-tools/injectable"; -import namespaceStoreInjectable from "../namespace-store/namespace-store.injectable"; +import namespaceStoreInjectable from "../store.injectable"; const namespaceSelectFilterModelInjectable = getInjectable({ id: "namespace-select-filter-model", diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts deleted file mode 100644 index dfdbd987ea..0000000000 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { observable, makeObservable, action, untracked } from "mobx"; -import type { NamespaceStore } from "../namespace-store/namespace.store"; -import type { SelectOption } from "../../select"; -import { isMac } from "../../../../common/vars"; - -interface Dependencies { - namespaceStore: NamespaceStore; -} - -export class NamespaceSelectFilterModel { - constructor(private dependencies: Dependencies) { - makeObservable(this, { - menuIsOpen: observable, - closeMenu: action, - openMenu: action, - reset: action, - }); - } - - menuIsOpen = false; - - closeMenu = () => { - this.menuIsOpen = false; - }; - - openMenu = () => { - this.menuIsOpen = true; - }; - - get selectedNames() { - return untracked(() => this.dependencies.namespaceStore.selectedNames); - } - - isSelected = (namespace: string | string[]) => - this.dependencies.namespaceStore.hasContext(namespace); - - selectSingle = (namespace: string) => { - this.dependencies.namespaceStore.selectSingle(namespace); - }; - - selectAll = () => { - this.dependencies.namespaceStore.selectAll(); - }; - - onChange = ([{ value: namespace }]: SelectOption[]) => { - if (namespace) { - if (this.isMultiSelection) { - this.dependencies.namespaceStore.toggleSingle(namespace); - } else { - this.dependencies.namespaceStore.selectSingle(namespace); - } - } else { - this.dependencies.namespaceStore.selectAll(); - } - }; - - onClick = () => { - if (!this.menuIsOpen) { - this.openMenu(); - } else if (!this.isMultiSelection) { - this.closeMenu(); - } - }; - - private isMultiSelection = false; - - onKeyDown = (event: React.KeyboardEvent) => { - if (isSelectionKey(event)) { - this.isMultiSelection = true; - } - }; - - onKeyUp = (event: React.KeyboardEvent) => { - if (isSelectionKey(event)) { - this.isMultiSelection = false; - } - }; - - reset = () => { - this.isMultiSelection = false; - this.closeMenu(); - }; -} - -const isSelectionKey = (event: React.KeyboardEvent): boolean => { - if (isMac) { - return event.key === "Meta"; - } - - return event.key === "Control"; // windows or linux -}; diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx new file mode 100644 index 0000000000..9731abf8b8 --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx @@ -0,0 +1,159 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { observable, action, untracked, computed, makeObservable } from "mobx"; +import type { NamespaceStore } from "../store"; +import { isMac } from "../../../../common/vars"; +import type { ActionMeta } from "react-select"; +import { Icon } from "../../icon"; +import type { SelectOption } from "../../select"; +import { autoBind } from "../../../utils"; + +interface Dependencies { + readonly namespaceStore: NamespaceStore; +} + +export const selectAllNamespaces = Symbol("all-namespaces-selected"); + +export type SelectAllNamespaces = typeof selectAllNamespaces; +export type NamespaceSelectFilterOption = SelectOption; + +export class NamespaceSelectFilterModel { + constructor(private readonly dependencies: Dependencies) { + makeObservable(this); + autoBind(this); + } + + readonly options = computed((): readonly NamespaceSelectFilterOption[] => { + const baseOptions = this.dependencies.namespaceStore.items.map(ns => ns.getName()); + + baseOptions.sort(( + (left, right) => + +this.selectedNames.has(right) + - +this.selectedNames.has(left) + )); + + return [ + { + value: selectAllNamespaces, + label: "All Namespaces", + isSelected: false, + }, + ...baseOptions.map(namespace => ({ + value: namespace, + label: namespace, + isSelected: this.selectedNames.has(namespace), + })), + ]; + }); + + formatOptionLabel({ value, isSelected }: NamespaceSelectFilterOption) { + if (value === selectAllNamespaces) { + return "All Namespaces"; + } + + return ( +
    + + {value} + {isSelected && ( + + )} +
    + ); + } + + readonly menuIsOpen = observable.box(false); + + @action + closeMenu() { + this.menuIsOpen.set(false); + } + + @action + openMenu(){ + this.menuIsOpen.set(true); + } + + get selectedNames() { + return untracked(() => this.dependencies.namespaceStore.selectedNames); + } + + isSelected(namespace: string | string[]) { + return this.dependencies.namespaceStore.hasContext(namespace); + } + + selectSingle(namespace: string) { + this.dependencies.namespaceStore.selectSingle(namespace); + } + + selectAll() { + this.dependencies.namespaceStore.selectAll(); + } + + onChange(namespace: unknown, action: ActionMeta) { + switch (action.action) { + case "clear": + this.dependencies.namespaceStore.selectAll(); + break; + case "deselect-option": + if (typeof action.option === "string") { + this.dependencies.namespaceStore.toggleSingle(action.option); + } + break; + case "select-option": + if (action.option?.value === selectAllNamespaces) { + this.dependencies.namespaceStore.selectAll(); + } else if (action.option) { + if (this.isMultiSelection) { + this.dependencies.namespaceStore.toggleSingle(action.option.value); + } else { + this.dependencies.namespaceStore.selectSingle(action.option.value); + } + } + break; + } + } + + onClick() { + if (!this.menuIsOpen.get()) { + this.openMenu(); + } else if (!this.isMultiSelection) { + this.closeMenu(); + } + } + + private isMultiSelection = false; + + onKeyDown(event: React.KeyboardEvent) { + if (isSelectionKey(event)) { + this.isMultiSelection = true; + } + } + + onKeyUp(event: React.KeyboardEvent) { + if (isSelectionKey(event)) { + this.isMultiSelection = false; + } + } + + @action + reset() { + this.isMultiSelection = false; + this.closeMenu(); + } +} + +const isSelectionKey = (event: React.KeyboardEvent): boolean => { + if (isMac) { + return event.key === "Meta"; + } + + return event.key === "Control"; // windows or linux +}; diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index f1db8122d9..67203f1100 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -7,77 +7,54 @@ import "./namespace-select-filter.scss"; import React from "react"; import { observer } from "mobx-react"; -import { components, type PlaceholderProps } from "react-select"; - -import { Icon } from "../icon"; -import { NamespaceSelect } from "./namespace-select"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; - -import type { SelectOption, SelectProps } from "../select"; +import type { PlaceholderProps } from "react-select"; +import { components } from "react-select"; +import type { NamespaceStore } from "./store"; +import { Select } from "../select"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { NamespaceSelectFilterModel } from "./namespace-select-filter-model/namespace-select-filter-model"; +import type { NamespaceSelectFilterModel, NamespaceSelectFilterOption, SelectAllNamespaces } from "./namespace-select-filter-model/namespace-select-filter-model"; import namespaceSelectFilterModelInjectable from "./namespace-select-filter-model/namespace-select-filter-model.injectable"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; +import namespaceStoreInjectable from "./store.injectable"; + +interface NamespaceSelectFilterProps { + id: string; +} interface Dependencies { model: NamespaceSelectFilterModel; } -const NonInjectedNamespaceSelectFilter = observer(({ model }: SelectProps & Dependencies) => ( +const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & NamespaceSelectFilterProps) => (
    - + id={id} isMulti={true} - menuIsOpen={model.menuIsOpen} + isClearable={false} + menuIsOpen={model.menuIsOpen.get()} components={{ Placeholder }} - showAllNamespacesOption={true} closeMenuOnSelect={false} controlShouldRenderValue={false} - placeholder={""} onChange={model.onChange} onBlur={model.reset} - formatOptionLabel={formatOptionLabelFor(model)} - className="NamespaceSelectFilter" - menuClass="NamespaceSelectFilterMenu" - sort={(left, right) => - +model.selectedNames.has(right.value) - - +model.selectedNames.has(left.value) - } - /> + formatOptionLabel={model.formatOptionLabel} + options={model.options.get()} + className="NamespaceSelect NamespaceSelectFilter" + menuClass="NamespaceSelectFilterMenu" />
    )); - -const formatOptionLabelFor = - (model: NamespaceSelectFilterModel) => - ({ value: namespace, label }: SelectOption) => { - if (namespace) { - const isSelected = model.isSelected(namespace); - - return ( -
    - - {namespace} - {isSelected && } -
    - ); - } - - return label; - }; - -export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { +export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { getProps: (di, props) => ({ model: di.inject(namespaceSelectFilterModelInjectable), ...props, }), }); -export interface CustomPlaceholderProps extends PlaceholderProps {} +export interface CustomPlaceholderProps extends PlaceholderProps {} interface PlaceholderDependencies { namespaceStore: NamespaceStore; @@ -103,8 +80,7 @@ const NonInjectedPlaceholder = observer(({ namespaceStore, ...props }: CustomPla {getPlaceholder()} ); -}, -); +}); const Placeholder = withInjectables( NonInjectedPlaceholder, { getProps: (di, props) => ({ diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index d28758b0cb..bcb855d585 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -5,98 +5,82 @@ import "./namespace-select.scss"; -import React from "react"; -import { computed, makeObservable } from "mobx"; +import React, { useEffect, useState } from "react"; +import { computed } from "mobx"; import { observer } from "mobx-react"; -import type { SelectOption, SelectProps } from "../select"; +import type { SelectProps } from "../select"; import { Select } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; +import type { NamespaceStore } from "./store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; +import namespaceStoreInjectable from "./store.injectable"; -export interface NamespaceSelectProps extends SelectProps { +export type NamespaceSelectSort = (left: string, right: string) => number; + +export interface NamespaceSelectProps extends Omit, "options" | "value"> { showIcons?: boolean; - sort?: (a: SelectOption, b: SelectOption) => number; - showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) - customizeOptions?(options: SelectOption[]): SelectOption[]; + sort?: NamespaceSelectSort; + value: string | null | undefined; } -const defaultProps: Partial = { - showIcons: true, -}; - interface Dependencies { namespaceStore: NamespaceStore; } -@observer -class NonInjectedNamespaceSelect extends React.Component { - static defaultProps = defaultProps as object; - - constructor(props: NamespaceSelectProps & Dependencies) { - super(props); - makeObservable(this); - } - - // No subscribe here because the subscribe is in (the cluster frame root component) - - @computed.struct get options(): SelectOption[] { - const { customizeOptions, showAllNamespacesOption, sort } = this.props; - let options: SelectOption[] = this.props.namespaceStore.items.map(ns => ({ value: ns.getName() })); +function getOptions(namespaceStore: NamespaceStore, sort: NamespaceSelectSort | undefined) { + return computed(() => { + const baseOptions = namespaceStore.items.map(ns => ns.getName()); if (sort) { - options.sort(sort); + baseOptions.sort(sort); } - if (showAllNamespacesOption) { - options.unshift({ label: "All Namespaces", value: "" }); - } - - if (customizeOptions) { - options = customizeOptions(options); - } - - return options; - } - - formatOptionLabel = (option: SelectOption) => { - const { showIcons } = this.props; - const { value, label } = option; - - return label || ( - <> - {showIcons && } - {value} - - ); - }; - - render() { - const { className, showIcons, customizeOptions, components = {}, namespaceStore, ...selectProps } = this.props; - - return ( - ( + <> + + {value} + + ) + : undefined + } + options={baseOptions.get()} + {...selectProps} + /> + ); +}); + +const InjectedNamespaceSelect = withInjectables>(NonInjectedNamespaceSelect, { + getProps: (di, props) => ({ + ...props, + namespaceStore: di.inject(namespaceStoreInjectable), + }), +}); + +export function NamespaceSelect(props: NamespaceSelectProps): JSX.Element { + return ; +} diff --git a/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts b/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts deleted file mode 100644 index 219094be1e..0000000000 --- a/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { NamespaceStore } from "./namespace.store"; -import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; - -const namespaceStoreInjectable = getInjectable({ - id: "namespace-store", - - instantiate: (di) => { - const createStorage = di.inject(createStorageInjectable); - - const storage = createStorage( - "selected_namespaces", - undefined, - ); - - const namespaceStore = new NamespaceStore({ - storage, - }); - - const apiManager = di.inject(apiManagerInjectable); - - apiManager.registerStore(namespaceStore); - - return namespaceStore; - }, -}); - -export default namespaceStoreInjectable; diff --git a/src/renderer/components/+namespaces/route.tsx b/src/renderer/components/+namespaces/route.tsx index 00956306ef..4bcc59041b 100644 --- a/src/renderer/components/+namespaces/route.tsx +++ b/src/renderer/components/+namespaces/route.tsx @@ -6,17 +6,17 @@ import "./namespaces.scss"; import React from "react"; -import { NamespaceStatus } from "../../../common/k8s-api/endpoints"; -import { AddNamespaceDialog } from "./add-namespace-dialog"; +import { NamespaceStatusKind } from "../../../common/k8s-api/endpoints"; +import { AddNamespaceDialog } from "./add-dialog/dialog"; import { TabLayout } from "../layout/tab-layout-2"; import { Badge } from "../badge"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; +import type { NamespaceStore } from "./store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; -import addNamespaceDialogModelInjectable from "./add-namespace-dialog-model/add-namespace-dialog-model.injectable"; +import namespaceStoreInjectable from "./store.injectable"; import { KubeObjectAge } from "../kube-object/age"; +import openAddNamepaceDialogInjectable from "./add-dialog/open.injectable"; enum columnId { name = "name", @@ -58,7 +58,13 @@ export const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDia renderTableContents={namespace => [ namespace.getName(), , - namespace.getLabels().map(label => ), + namespace.getLabels().map(label => ( + + )), , { title: namespace.getStatus(), className: namespace.getStatus().toLowerCase() }, ]} @@ -67,7 +73,7 @@ export const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDia onAdd: openAddNamespaceDialog, }} customizeTableRowProps={item => ({ - disabled: item.getStatus() === NamespaceStatus.TERMINATING, + disabled: item.getStatus() === NamespaceStatusKind.TERMINATING, })} /> @@ -78,6 +84,6 @@ export const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDia export const NamespacesRoute = withInjectables(NonInjectedNamespacesRoute, { getProps: (di) => ({ namespaceStore: di.inject(namespaceStoreInjectable), - openAddNamespaceDialog: di.inject(addNamespaceDialogModelInjectable).open, + openAddNamespaceDialog: di.inject(openAddNamepaceDialogInjectable), }), }); diff --git a/src/renderer/components/+namespaces/store.injectable.ts b/src/renderer/components/+namespaces/store.injectable.ts new file mode 100644 index 0000000000..68d611e837 --- /dev/null +++ b/src/renderer/components/+namespaces/store.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { NamespaceStore } from "./store"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; +import namespaceApiInjectable from "../../../common/k8s-api/endpoints/namespace.api.injectable"; +import assert from "assert"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; + +const namespaceStoreInjectable = getInjectable({ + id: "namespace-store", + + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "namespaceStore is only available in certain environments"); + + const createStorage = di.inject(createStorageInjectable); + const api = di.inject(namespaceApiInjectable); + + return new NamespaceStore({ + storage: createStorage("selected_namespaces", undefined), + }, api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default namespaceStoreInjectable; diff --git a/src/renderer/components/+namespaces/namespace-store/namespace.store.ts b/src/renderer/components/+namespaces/store.ts similarity index 87% rename from src/renderer/components/+namespaces/namespace-store/namespace.store.ts rename to src/renderer/components/+namespaces/store.ts index 3c02d880c1..1a242839fa 100644 --- a/src/renderer/components/+namespaces/namespace-store/namespace.store.ts +++ b/src/renderer/components/+namespaces/store.ts @@ -5,21 +5,20 @@ import type { IReactionDisposer } from "mobx"; import { action, comparer, computed, makeObservable, reaction } from "mobx"; -import type { StorageHelper } from "../../../utils"; -import { autoBind, noop, toggle } from "../../../utils"; -import type { KubeObjectStoreLoadingParams } from "../../../../common/k8s-api/kube-object.store"; -import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import { Namespace, namespacesApi } from "../../../../common/k8s-api/endpoints/namespaces.api"; +import type { StorageLayer } from "../../utils"; +import { autoBind, noop, toggle } from "../../utils"; +import type { KubeObjectStoreLoadingParams } from "../../../common/k8s-api/kube-object.store"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { NamespaceApi } from "../../../common/k8s-api/endpoints/namespace.api"; +import { Namespace } from "../../../common/k8s-api/endpoints/namespace.api"; interface Dependencies { - storage: StorageHelper; + storage: StorageLayer; } -export class NamespaceStore extends KubeObjectStore { - api = namespacesApi; - - constructor(private dependencies: Dependencies) { - super(); +export class NamespaceStore extends KubeObjectStore { + constructor(protected readonly dependencies: Dependencies, api: NamespaceApi) { + super(api); makeObservable(this); autoBind(this); @@ -71,7 +70,7 @@ export class NamespaceStore extends KubeObjectStore { * @private * The current value (list of namespaces names) in the storage layer */ - @computed private get selectedNamespaces(): string[] { + @computed private get selectedNamespaces() { return this.dependencies.storage.get() ?? []; } @@ -85,7 +84,7 @@ export class NamespaceStore extends KubeObjectStore { /** * The list of selected namespace names (for filtering) */ - @computed get contextNamespaces(): string[] { + @computed get contextNamespaces() { if (!this.selectedNamespaces.length) { return this.allowedNamespaces; // show all namespaces when nothing selected } @@ -115,7 +114,7 @@ export class NamespaceStore extends KubeObjectStore { * if user has given static list of namespaces let's not start watches * because watch adds stuff that's not wanted or will just fail */ - if (this.context?.cluster.accessibleNamespaces.length > 0) { + if (this.context.cluster.accessibleNamespaces.length > 0) { return noop; } @@ -146,7 +145,7 @@ export class NamespaceStore extends KubeObjectStore { clearSelected(namespaces?: string | string[]) { if (namespaces) { const resettingNamespaces = [namespaces].flat(); - const newNamespaces = this.dependencies.storage.get().filter(ns => !resettingNamespaces.includes(ns)); + const newNamespaces = this.dependencies.storage.get()?.filter(ns => !resettingNamespaces.includes(ns)); this.dependencies.storage.set(newNamespaces); } else { diff --git a/src/renderer/components/+network-endpoints/endpoint-details.tsx b/src/renderer/components/+network-endpoints/endpoint-details.tsx index 13a3a99ff0..3821c68047 100644 --- a/src/renderer/components/+network-endpoints/endpoint-details.tsx +++ b/src/renderer/components/+network-endpoints/endpoint-details.tsx @@ -9,16 +9,16 @@ import React from "react"; import { observer } from "mobx-react"; import { DrawerTitle } from "../drawer"; import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { Endpoint } from "../../../common/k8s-api/endpoints"; +import { Endpoints } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; import { EndpointSubsetList } from "./endpoint-subset-list"; import logger from "../../../common/logger"; -export interface EndpointDetailsProps extends KubeObjectDetailsProps { +export interface EndpointsDetailsProps extends KubeObjectDetailsProps { } @observer -export class EndpointDetails extends React.Component { +export class EndpointsDetails extends React.Component { render() { const { object: endpoint } = this.props; @@ -26,7 +26,7 @@ export class EndpointDetails extends React.Component { return null; } - if (!(endpoint instanceof Endpoint)) { + if (!(endpoint instanceof Endpoints)) { logger.error("[EndpointDetails]: passed object that is not an instanceof Endpoint", endpoint); return null; @@ -38,7 +38,11 @@ export class EndpointDetails extends React.Component { Subsets { endpoint.getEndpointSubsets().map((subset) => ( - + )) } diff --git a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx index ec27f77134..700f1fdbf8 100644 --- a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx +++ b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx @@ -7,35 +7,41 @@ import "./endpoint-subset-list.scss"; import React from "react"; import { observer } from "mobx-react"; -import type { EndpointSubset, Endpoint, EndpointAddress } from "../../../common/k8s-api/endpoints"; +import type { EndpointSubset, Endpoints, EndpointAddress } from "../../../common/k8s-api/endpoints"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; import { Link } from "react-router-dom"; -import { getDetailsUrl } from "../kube-detail-params"; import { autoBind } from "../../../common/utils"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable"; +import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable"; export interface EndpointSubsetListProps { - subset: EndpointSubset; - endpoint: Endpoint; + subset: Required; + endpoint: Endpoints; +} + +interface Dependencies { + apiManager: ApiManager; + getDetailsUrl: GetDetailsUrl; } @observer -export class EndpointSubsetList extends React.Component { - constructor(props: EndpointSubsetListProps) { +class NonInjectedEndpointSubsetList extends React.Component { + constructor(props: EndpointSubsetListProps & Dependencies) { super(props); autoBind(this); } getAddressTableRow(ip: string) { - const { subset } = this.props; - const address = subset.getAddresses().find(address => address.getId() == ip); + const address = this.props.subset.addresses.find(address => address.ip == ip); return this.renderAddressTableRow(address); } getNotReadyAddressTableRow(ip: string) { - const { subset } = this.props; - const address = subset.getNotReadyAddresses().find(address => address.getId() == ip); + const address = this.props.subset.notReadyAddresses.find(address => address.ip == ip); return this.renderAddressTableRow(address); } @@ -58,26 +64,30 @@ export class EndpointSubsetList extends React.Component Target { - !virtual && addresses.map(address => this.getAddressTableRow(address.getId())) + !virtual && addresses.map(address => this.getAddressTableRow(address.ip)) }
    ); } - renderAddressTableRow(address: EndpointAddress) { - const { endpoint } = this.props; + renderAddressTableRow(address: EndpointAddress | undefined) { + if (!address) { + return undefined; + } + + const { endpoint, getDetailsUrl, apiManager } = this.props; return ( {address.ip} {address.hostname} { address.targetRef && ( - + {address.targetRef.name} )} @@ -87,9 +97,7 @@ export class EndpointSubsetList extends React.Component } render() { - const { subset } = this.props; - const addresses = subset.getAddresses(); - const notReadyAddresses = subset.getNotReadyAddresses(); + const { subset: { addresses, ports, notReadyAddresses }} = this.props; const addressesVirtual = addresses.length > 100; const notReadyAddressesVirtual = notReadyAddresses.length > 100; @@ -111,7 +119,7 @@ export class EndpointSubsetList extends React.Component Hostname Target - { !addressesVirtual && addresses.map(address => this.getAddressTableRow(address.getId())) } + { !addressesVirtual && addresses.map(address => this.getAddressTableRow(address.ip)) } )} @@ -132,7 +140,7 @@ export class EndpointSubsetList extends React.Component Hostname Target - { !notReadyAddressesVirtual && notReadyAddresses.map(address => this.getNotReadyAddressTableRow(address.getId())) } + { !notReadyAddressesVirtual && notReadyAddresses.map(address => this.getNotReadyAddressTableRow(address.ip)) } )} @@ -150,21 +158,27 @@ export class EndpointSubsetList extends React.Component Protocol { - subset.ports.map(port => { - return ( - - {port.port} - {port.name} - {port.protocol} - - ); - }) + ports.map(port => ( + + {port.port} + {port.name} + {port.protocol} + + )) } ); } } + +export const EndpointSubsetList = withInjectables(NonInjectedEndpointSubsetList, { + getProps: (di, props) => ({ + ...props, + apiManager: di.inject(apiManagerInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + }), +}); diff --git a/src/renderer/components/+network-endpoints/endpoints.store.ts b/src/renderer/components/+network-endpoints/endpoints.store.ts deleted file mode 100644 index 3b80f63465..0000000000 --- a/src/renderer/components/+network-endpoints/endpoints.store.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { Endpoint } from "../../../common/k8s-api/endpoints/endpoint.api"; -import { endpointApi } from "../../../common/k8s-api/endpoints/endpoint.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class EndpointStore extends KubeObjectStore { - api = endpointApi; -} - -export const endpointStore = new EndpointStore(); -apiManager.registerStore(endpointStore); diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx index b1411f9681..d97c2be98b 100644 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ b/src/renderer/components/+network-endpoints/endpoints.tsx @@ -7,7 +7,7 @@ import "./endpoints.scss"; import React from "react"; import { observer } from "mobx-react"; -import { endpointStore } from "./endpoints.store"; +import { endpointsStore } from "./legacy-store"; import { KubeObjectListLayout } from "../kube-object-list-layout"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout"; @@ -28,7 +28,8 @@ export class Endpoints extends React.Component { endpoint.getName(), [columnId.namespace]: endpoint => endpoint.getNs(), diff --git a/src/renderer/components/+network-endpoints/legacy-store.ts b/src/renderer/components/+network-endpoints/legacy-store.ts new file mode 100644 index 0000000000..f66d144619 --- /dev/null +++ b/src/renderer/components/+network-endpoints/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import endpointsStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(endpointsStoreInjectable)` + */ +export const endpointsStore = asLegacyGlobalForExtensionApi(endpointsStoreInjectable); diff --git a/src/renderer/components/+network-endpoints/store.injectable.ts b/src/renderer/components/+network-endpoints/store.injectable.ts new file mode 100644 index 0000000000..a63014ea70 --- /dev/null +++ b/src/renderer/components/+network-endpoints/store.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import endpointsApiInjectable from "../../../common/k8s-api/endpoints/endpoint.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { EndpointsStore } from "./store"; + +const endpointsStoreInjectable = getInjectable({ + id: "endpoints-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "endpointsStore is only available in certain environments"); + + const api = di.inject(endpointsApiInjectable); + + return new EndpointsStore(api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default endpointsStoreInjectable; diff --git a/src/renderer/components/+network-endpoints/store.ts b/src/renderer/components/+network-endpoints/store.ts new file mode 100644 index 0000000000..5cd9e1033e --- /dev/null +++ b/src/renderer/components/+network-endpoints/store.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { Endpoints, EndpointsApi, EndpointsData } from "../../../common/k8s-api/endpoints/endpoint.api"; + +export class EndpointsStore extends KubeObjectStore { +} diff --git a/src/renderer/components/+network-ingresses/ingress-charts.tsx b/src/renderer/components/+network-ingresses/ingress-charts.tsx index c82493c652..cc8d02ee76 100644 --- a/src/renderer/components/+network-ingresses/ingress-charts.tsx +++ b/src/renderer/components/+network-ingresses/ingress-charts.tsx @@ -5,23 +5,20 @@ import React, { useContext } from "react"; import { observer } from "mobx-react"; -import type { ChartOptions, ChartPoint } from "chart.js"; -import type { IIngressMetrics, Ingress } from "../../../common/k8s-api/endpoints"; -import { BarChart, memoryOptions } from "../chart"; +import type { ChartDataSets } from "../chart"; +import { BarChart } from "../chart"; import { normalizeMetrics, isMetricsEmpty } from "../../../common/k8s-api/endpoints/metrics.api"; import { NoMetrics } from "../resource-metrics/no-metrics"; -import type { IResourceMetricsValue } from "../resource-metrics"; import { ResourceMetricsContext } from "../resource-metrics"; - -type IContext = IResourceMetricsValue; +import { type MetricsTab, metricTabOptions } from "../chart/options"; export const IngressCharts = observer(() => { - const { params: { metrics }, tabId, object } = useContext(ResourceMetricsContext); - const id = object.getId(); + const { metrics, tab, object } = useContext(ResourceMetricsContext) ?? {}; - if (!metrics) return null; + if (!metrics || !object || !tab) return null; if (isMetricsEmpty(metrics)) return ; + const id = object.getId(); const values = Object.values(metrics) .map(normalizeMetrics) .map(({ data }) => data.result[0].values); @@ -32,9 +29,8 @@ export const IngressCharts = observer(() => { responseDurationSeconds, ] = values; - const datasets = [ - // Network - [ + const datasets: Partial> = { + Network: [ { id: `${id}-bytesSentSuccess`, label: `Bytes sent, status 2xx`, @@ -50,8 +46,7 @@ export const IngressCharts = observer(() => { data: bytesSentFailure.map(([x, y]) => ({ x, y })), }, ], - // Duration - [ + Duration: [ { id: `${id}-requestDurationSeconds`, label: `Request`, @@ -67,36 +62,13 @@ export const IngressCharts = observer(() => { data: responseDurationSeconds.map(([x, y]) => ({ x, y })), }, ], - ]; - - const durationOptions: ChartOptions = { - scales: { - yAxes: [{ - ticks: { - callback: value => value, - }, - }], - }, - tooltips: { - callbacks: { - label: ({ datasetIndex, index }, { datasets }) => { - const { label, data } = datasets[datasetIndex]; - const value = data[index] as ChartPoint; - const chartTooltipSec = `sec`; - - return `${label}: ${parseFloat(value.y as string).toFixed(3)} ${chartTooltipSec}`; - }, - }, - }, }; - const options = [memoryOptions, durationOptions]; - return ( ); }); diff --git a/src/renderer/components/+network-ingresses/ingress-details.tsx b/src/renderer/components/+network-ingresses/ingress-details.tsx index bb088be389..ae4c06b6d8 100644 --- a/src/renderer/components/+network-ingresses/ingress-details.tsx +++ b/src/renderer/components/+network-ingresses/ingress-details.tsx @@ -16,8 +16,8 @@ import { ResourceMetrics } from "../resource-metrics"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { IngressCharts } from "./ingress-charts"; import { KubeObjectMeta } from "../kube-object-meta"; -import { computeRuleDeclarations, getMetricsForIngress, type IIngressMetrics } from "../../../common/k8s-api/endpoints/ingress.api"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; +import { computeRuleDeclarations, getMetricsForIngress, type IngressMetricData } from "../../../common/k8s-api/endpoints/ingress.api"; +import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import logger from "../../../common/logger"; @@ -26,7 +26,7 @@ export interface IngressDetailsProps extends KubeObjectDetailsProps { @observer export class IngressDetails extends React.Component { - @observable metrics: IIngressMetrics = null; + @observable metrics: IngressMetricData | null = null; constructor(props: IngressDetailsProps) { super(props); @@ -53,7 +53,7 @@ export class IngressDetails extends React.Component {
    {rule.host && (
    - <>Host: {rule.host} + {`Host: ${rule.host}`}
    )} {rule.http && ( @@ -72,7 +72,11 @@ export class IngressDetails extends React.Component { { displayAsLink ? ( - + {url} ) @@ -125,22 +129,20 @@ export class IngressDetails extends React.Component { return null; } - const { spec, status } = ingress; - const ingressPoints = status?.loadBalancer?.ingress; - const { metrics } = this; - const metricTabs = [ - "Network", - "Duration", - ]; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Ingress); - const { serviceName, servicePort } = ingress.getServiceNamePort(); + const port = ingress.getServiceNamePort(); return (
    {!isMetricHidden && ( @@ -149,21 +151,21 @@ export class IngressDetails extends React.Component { {ingress.getPorts()} - {spec.tls && - - {spec.tls.map((tls, index) =>

    {tls.secretName}

    )} -
    - } - {serviceName && servicePort && - - {serviceName}:{servicePort} - - } + {ingress.spec.tls && ( + + {ingress.spec.tls.map((tls, index) =>

    {tls.secretName}

    )} +
    + )} + {port && ( + + {`${port.serviceName}:${port.servicePort}`} + + )} Rules {this.renderPaths(ingress)} Load-Balancer Ingress Points - {this.renderIngressPoints(ingressPoints)} + {this.renderIngressPoints(ingress.status?.loadBalancer?.ingress ?? [])}
    ); } diff --git a/src/renderer/components/+network-ingresses/ingress.store.ts b/src/renderer/components/+network-ingresses/ingress.store.ts deleted file mode 100644 index 4cb3ef7d1a..0000000000 --- a/src/renderer/components/+network-ingresses/ingress.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { apiManager } from "../../../common/k8s-api/api-manager"; -import type { Ingress } from "../../../common/k8s-api/endpoints"; -import { ingressApi } from "../../../common/k8s-api/endpoints"; -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; - -export class IngressStore extends KubeObjectStore { - api = ingressApi; -} - -export const ingressStore = new IngressStore(); -apiManager.registerStore(ingressStore); diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx index a585025324..e117bc5a8e 100644 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ b/src/renderer/components/+network-ingresses/ingresses.tsx @@ -7,7 +7,7 @@ import "./ingresses.scss"; import React from "react"; import { observer } from "mobx-react"; -import { ingressStore } from "./ingress.store"; +import { ingressStore } from "./legacy-store"; import { KubeObjectListLayout } from "../kube-object-list-layout"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout"; @@ -30,7 +30,8 @@ export class Ingresses extends React.Component { ingress.getName(), [columnId.namespace]: ingress => ingress.getNs(), @@ -65,10 +66,15 @@ export class Ingresses extends React.Component { onClick={e => e.stopPropagation()} > {decl.url} - ⇢ {decl.service} + + {` ⇢ ${decl.service}`} + + ) + : ( + + {`${decl.url} ⇢ ${decl.service}`} ) - : {decl.url} ⇢ {decl.service} )), , ]} diff --git a/src/renderer/components/+network-ingresses/legacy-store.ts b/src/renderer/components/+network-ingresses/legacy-store.ts new file mode 100644 index 0000000000..0731109f5d --- /dev/null +++ b/src/renderer/components/+network-ingresses/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import ingressStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(ingressStoreInjectable)` instead + */ +export const ingressStore = asLegacyGlobalForExtensionApi(ingressStoreInjectable); diff --git a/src/renderer/components/+network-ingresses/store.injectable.ts b/src/renderer/components/+network-ingresses/store.injectable.ts new file mode 100644 index 0000000000..9b95939e9f --- /dev/null +++ b/src/renderer/components/+network-ingresses/store.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import ingressApiInjectable from "../../../common/k8s-api/endpoints/ingress.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { IngressStore } from "./store"; + +const ingressStoreInjectable = getInjectable({ + id: "ingress-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "ingressStore is only available in certain environments"); + + const api = di.inject(ingressApiInjectable); + + return new IngressStore(api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default ingressStoreInjectable; diff --git a/src/renderer/components/+network-ingresses/store.ts b/src/renderer/components/+network-ingresses/store.ts new file mode 100644 index 0000000000..ef86befb72 --- /dev/null +++ b/src/renderer/components/+network-ingresses/store.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Ingress, IngressApi } from "../../../common/k8s-api/endpoints"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; + +export class IngressStore extends KubeObjectStore { +} diff --git a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx index 294fc323ae..0a4e9b22d7 100644 --- a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx +++ b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx @@ -5,34 +5,44 @@ import React from "react"; import { findByTestId, findByText, render } from "@testing-library/react"; -import type { NetworkPolicySpec } from "../../../../common/k8s-api/endpoints"; import { NetworkPolicy } from "../../../../common/k8s-api/endpoints"; import { NetworkPolicyDetails } from "../network-policy-details"; -jest.mock("../../kube-object-meta"); +jest.mock("../../kube-object-meta/kube-object-meta", () => ({ + KubeObjectMeta: () => null, +})); describe("NetworkPolicyDetails", () => { it("should render w/o errors", () => { - const policy = new NetworkPolicy({ metadata: {} as any, spec: {}} as any); + const policy = new NetworkPolicy({ + metadata: {} as never, + spec: {} as never, + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + }); const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); it("should render egress nodeSelector", async () => { - const spec: NetworkPolicySpec = { - egress: [{ - to: [{ - namespaceSelector: { - matchLabels: { - foo: "bar", + const policy = new NetworkPolicy({ + metadata: {} as never, + spec: { + egress: [{ + to: [{ + namespaceSelector: { + matchLabels: { + foo: "bar", + }, }, - }, + }], }], - }], - podSelector: {}, - }; - const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + podSelector: {}, + }, + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + }); const { container } = render(); expect(await findByTestId(container, "egress-0")).toBeInstanceOf(HTMLElement); @@ -40,15 +50,19 @@ describe("NetworkPolicyDetails", () => { }); it("should not crash if egress nodeSelector doesn't have matchLabels", async () => { - const spec: NetworkPolicySpec = { - egress: [{ - to: [{ - namespaceSelector: {}, + const policy = new NetworkPolicy({ + metadata: {} as never, + spec: { + egress: [{ + to: [{ + namespaceSelector: {}, + }], }], - }], - podSelector: {}, - }; - const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + podSelector: {}, + }, + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + }); const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); diff --git a/src/renderer/components/+network-policies/legacy-store.ts b/src/renderer/components/+network-policies/legacy-store.ts new file mode 100644 index 0000000000..b8da3f6081 --- /dev/null +++ b/src/renderer/components/+network-policies/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import networkPolicyStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(networkPolicyStoreInjectable)` instead + */ +export const networkPolicyStore = asLegacyGlobalForExtensionApi(networkPolicyStoreInjectable); diff --git a/src/renderer/components/+network-policies/network-policies.tsx b/src/renderer/components/+network-policies/network-policies.tsx index 9a442677d7..88b1425802 100644 --- a/src/renderer/components/+network-policies/network-policies.tsx +++ b/src/renderer/components/+network-policies/network-policies.tsx @@ -8,7 +8,7 @@ import "./network-policies.scss"; import React from "react"; import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { networkPolicyStore } from "./network-policy.store"; +import { networkPolicyStore } from "./legacy-store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout"; import { KubeObjectAge } from "../kube-object/age"; diff --git a/src/renderer/components/+network-policies/network-policy-details.tsx b/src/renderer/components/+network-policies/network-policy-details.tsx index f04cdac495..264d626ba9 100644 --- a/src/renderer/components/+network-policies/network-policy-details.tsx +++ b/src/renderer/components/+network-policies/network-policy-details.tsx @@ -53,7 +53,11 @@ export class NetworkPolicyDetails extends React.Component
  • {key}: {value}
  • ); + .map(([key, value]) => ( +
  • + {`${key}: ${value}`} +
  • + )); } renderMatchExpressions(matchExpressions: LabelMatchExpression[] | undefined) { @@ -65,12 +69,16 @@ export class NetworkPolicyDetails extends React.Component{expr.key} ({expr.operator}); + return ( +
  • + {`${expr.key} (${expr.operator})`} +
  • + ); case "In": case "NotIn": return (
  • - {expr.key}({expr.operator}) + {`${expr.key} (${expr.operator})`}
      {expr.values.map((value, index) =>
    • {value}
    • )}
    @@ -133,7 +141,10 @@ export class NetworkPolicyDetails extends React.Component {ports.map(({ protocol = "TCP", port = "", endPort }, index) => (
  • - {protocol}:{port}{typeof endPort === "number" && `:${endPort}`} + {protocol} + : + {port} + {typeof endPort === "number" && `:${endPort}`}
  • ))} diff --git a/src/renderer/components/+network-policies/network-policy.store.ts b/src/renderer/components/+network-policies/network-policy.store.ts deleted file mode 100644 index 4b5908afd7..0000000000 --- a/src/renderer/components/+network-policies/network-policy.store.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { NetworkPolicy } from "../../../common/k8s-api/endpoints/network-policy.api"; -import { networkPolicyApi } from "../../../common/k8s-api/endpoints/network-policy.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class NetworkPolicyStore extends KubeObjectStore { - api = networkPolicyApi; -} - -export const networkPolicyStore = new NetworkPolicyStore(); -apiManager.registerStore(networkPolicyStore); diff --git a/src/renderer/components/+network-policies/store.injectable.ts b/src/renderer/components/+network-policies/store.injectable.ts new file mode 100644 index 0000000000..a666c04cb4 --- /dev/null +++ b/src/renderer/components/+network-policies/store.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable"; +import networkPolicyApiInjectable from "../../../common/k8s-api/endpoints/network-policy.api.injectable"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { NetworkPolicyStore } from "./store"; + +const networkPolicyStoreInjectable = getInjectable({ + id: "network-policy-store", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectable), "networkPolicyStore is only available in certain environments"); + + const api = di.inject(networkPolicyApiInjectable); + + return new NetworkPolicyStore(api); + }, + injectionToken: kubeObjectStoreInjectionToken, +}); + +export default networkPolicyStoreInjectable; diff --git a/src/renderer/components/+network-policies/store.ts b/src/renderer/components/+network-policies/store.ts new file mode 100644 index 0000000000..b5fcf27db9 --- /dev/null +++ b/src/renderer/components/+network-policies/store.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { NetworkPolicy, NetworkPolicyApi } from "../../../common/k8s-api/endpoints/network-policy.api"; + +export class NetworkPolicyStore extends KubeObjectStore { +} diff --git a/src/renderer/components/+network-port-forwards/port-forward-details.tsx b/src/renderer/components/+network-port-forwards/port-forward-details.tsx index 4ab2db0264..dbc3156660 100644 --- a/src/renderer/components/+network-port-forwards/port-forward-details.tsx +++ b/src/renderer/components/+network-port-forwards/port-forward-details.tsx @@ -11,7 +11,7 @@ import type { PortForwardItem } from "../../port-forward"; import { portForwardAddress } from "../../port-forward"; import { Drawer, DrawerItem } from "../drawer"; import { cssNames } from "../../utils"; -import { podsApi, serviceApi } from "../../../common/k8s-api/endpoints"; +import { podApi, serviceApi } from "../../../common/k8s-api/endpoints"; import { getDetailsUrl } from "../kube-detail-params"; import { PortForwardMenu } from "./port-forward-menu"; @@ -27,7 +27,7 @@ export class PortForwardDetails extends React.Component const name = portForward.getName(); const api = { "service": serviceApi, - "pod": podsApi, + "pod": podApi, }[portForward.kind]; if (!api) { @@ -77,7 +77,13 @@ export class PortForwardDetails extends React.Component render() { const { hideDetails, portForward } = this.props; - const toolbar = ; + const toolbar = ( + + ); return ( this.portForwardStore.stop(portForward)}> - + Stop ); @@ -72,7 +76,11 @@ class NonInjectedPortForwardMenu - + Start ); @@ -85,14 +93,22 @@ class NonInjectedPortForwardMenu - { portForward.status === "Active" && + { portForward.status === "Active" && ( openPortForward(portForward)}> - + Open - } + )} this.props.openPortForwardDialog(portForward)}> - + Edit {this.renderStartStopMenuItem()} diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx index 489af01d62..fc6b4c4735 100644 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -53,7 +53,13 @@ class NonInjectedPortForwards extends React.Component { @computed get selectedPortForward() { - return this.props.portForwardStore.getById(this.props.forwardport.get()); + const forwardport = this.props.forwardport.get(); + + if (!forwardport) { + return undefined; + } + + return this.props.portForwardStore.getById(forwardport); } onDetails = (item: PortForwardItem) => { @@ -79,7 +85,11 @@ class NonInjectedPortForwards extends React.Component { return (
    - <>Stop forwarding from {forwardPorts}? + <> + {"Stop forwarding from "} + {forwardPorts} + ? +
    ); } diff --git a/src/renderer/components/+network-services/legacy-store.ts b/src/renderer/components/+network-services/legacy-store.ts new file mode 100644 index 0000000000..507136ef3e --- /dev/null +++ b/src/renderer/components/+network-services/legacy-store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import serviceStoreInjectable from "./store.injectable"; + +/** + * @deprecated use `di.inject(serviceStoreInjectable)` instead + */ +export const serviceStore = asLegacyGlobalForExtensionApi(serviceStoreInjectable); diff --git a/src/renderer/components/+network-services/service-details-endpoint.tsx b/src/renderer/components/+network-services/service-details-endpoint.tsx index 936bec2302..8d1e0f6166 100644 --- a/src/renderer/components/+network-services/service-details-endpoint.tsx +++ b/src/renderer/components/+network-services/service-details-endpoint.tsx @@ -3,29 +3,36 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { observer } from "mobx-react"; import React from "react"; import { Table, TableHead, TableCell, TableRow } from "../table"; import { prevDefault } from "../../utils"; -import { endpointStore } from "../+network-endpoints/endpoints.store"; +import { endpointsStore } from "../+network-endpoints/legacy-store"; import { Spinner } from "../spinner"; import { showDetails } from "../kube-detail-params"; +import logger from "../../../common/logger"; +import { Endpoints } from "../../../common/k8s-api/endpoints"; export interface ServiceDetailsEndpointProps { - endpoint: KubeObject; + endpoints: Endpoints; } @observer export class ServiceDetailsEndpoint extends React.Component { render() { - const { endpoint } = this.props; + const { endpoints } = this.props; - if (!endpoint && !endpointStore.isLoaded) return ( + if (!endpoints && !endpointsStore.isLoaded) return (
    ); - if (!endpoint) { + if (!endpoints) { + return null; + } + + if (!(endpoints instanceof Endpoints)) { + logger.error("[ServiceDetailsEndpoint]: passed object that is not an instanceof Endpoints", endpoints); + return null; } @@ -42,12 +49,12 @@ export class ServiceDetailsEndpoint extends React.ComponentEndpoints showDetails(endpoint.selfLink, false))} + onClick={prevDefault(() => showDetails(endpoints.selfLink, false))} > - {endpoint.getName()} - { endpoint.toString()} + {endpoints.getName()} + { endpoints.toString()}
    diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 9bfa65bbbc..61a45c4040 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -13,44 +13,47 @@ import type { KubeObjectDetailsProps } from "../kube-object-details"; import { Service } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; import { ServicePortComponent } from "./service-port-component"; -import { endpointStore } from "../+network-endpoints/endpoints.store"; +import type { EndpointsStore } from "../+network-endpoints/store"; import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import type { PortForwardStore } from "../../port-forward"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import type { Disposer } from "../../../common/utils"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import type { KubeWatchSubscribeStoreOptions } from "../../kube-watch-api/kube-watch-api"; +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; +import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable"; +import endpointsStoreInjectable from "../+network-endpoints/store.injectable"; export interface ServiceDetailsProps extends KubeObjectDetailsProps { } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer; + subscribeStores: SubscribeStores; portForwardStore: PortForwardStore; + endpointsStore: EndpointsStore; } @observer class NonInjectedServiceDetails extends React.Component { componentDidMount() { - const { object: service } = this.props; + const { + object: service, + subscribeStores, + endpointsStore, + portForwardStore, + } = this.props; disposeOnUnmount(this, [ - this.props.subscribeStores([ - endpointStore, + subscribeStores([ + endpointsStore, ], { namespaces: [service.getNs()], }), - this.props.portForwardStore.watch(), + portForwardStore.watch(), ]); } render() { - const { object: service } = this.props; + const { object: service, endpointsStore } = this.props; if (!service) { return null; @@ -63,7 +66,7 @@ class NonInjectedServiceDetails extends React.Component -