From e0c0e40b029c57a893aff63539705014a16a0980 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 26 Apr 2021 14:35:38 +0300 Subject: [PATCH 1/8] Disable webpack minimize (#2618) Signed-off-by: Jari Kolehmainen --- .../webpack.config.js | 3 + .../metrics-cluster-feature/webpack.config.js | 3 + extensions/node-menu/webpack.config.js | 3 + extensions/pod-menu/webpack.config.js | 3 + package.json | 2 - webpack.renderer.ts | 16 +-- yarn.lock | 131 +----------------- 7 files changed, 20 insertions(+), 141 deletions(-) diff --git a/extensions/kube-object-event-status/webpack.config.js b/extensions/kube-object-event-status/webpack.config.js index a35cf40850..6d5de7a2f8 100644 --- a/extensions/kube-object-event-status/webpack.config.js +++ b/extensions/kube-object-event-status/webpack.config.js @@ -6,6 +6,9 @@ module.exports = [ context: __dirname, target: "electron-renderer", mode: "production", + optimization: { + minimize: false + }, module: { rules: [ { diff --git a/extensions/metrics-cluster-feature/webpack.config.js b/extensions/metrics-cluster-feature/webpack.config.js index 8d3ea196ba..2df74b3e35 100644 --- a/extensions/metrics-cluster-feature/webpack.config.js +++ b/extensions/metrics-cluster-feature/webpack.config.js @@ -6,6 +6,9 @@ module.exports = [ context: __dirname, target: "electron-renderer", mode: "production", + optimization: { + minimize: false + }, module: { rules: [ { diff --git a/extensions/node-menu/webpack.config.js b/extensions/node-menu/webpack.config.js index a35cf40850..6d5de7a2f8 100644 --- a/extensions/node-menu/webpack.config.js +++ b/extensions/node-menu/webpack.config.js @@ -6,6 +6,9 @@ module.exports = [ context: __dirname, target: "electron-renderer", mode: "production", + optimization: { + minimize: false + }, module: { rules: [ { diff --git a/extensions/pod-menu/webpack.config.js b/extensions/pod-menu/webpack.config.js index a35cf40850..6d5de7a2f8 100644 --- a/extensions/pod-menu/webpack.config.js +++ b/extensions/pod-menu/webpack.config.js @@ -6,6 +6,9 @@ module.exports = [ context: __dirname, target: "electron-renderer", mode: "production", + optimization: { + minimize: false + }, module: { rules: [ { diff --git a/package.json b/package.json index e92fe3b092..a9bea801ad 100644 --- a/package.json +++ b/package.json @@ -280,7 +280,6 @@ "@types/tar": "^4.0.4", "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", - "@types/terser-webpack-plugin": "^3.0.0", "@types/universal-analytics": "^0.4.4", "@types/url-parse": "^1.4.3", "@types/uuid": "^8.3.0", @@ -340,7 +339,6 @@ "sharp": "^0.26.1", "spectron": "11.0.0", "style-loader": "^1.2.1", - "terser-webpack-plugin": "^3.0.3", "ts-jest": "^26.1.0", "ts-loader": "^7.0.5", "ts-node": "^8.10.2", diff --git a/webpack.renderer.ts b/webpack.renderer.ts index 1c0da17313..5b125bfbcb 100755 --- a/webpack.renderer.ts +++ b/webpack.renderer.ts @@ -3,7 +3,6 @@ import path from "path"; import webpack from "webpack"; import HtmlWebpackPlugin from "html-webpack-plugin"; import MiniCssExtractPlugin from "mini-css-extract-plugin"; -import TerserPlugin from "terser-webpack-plugin"; import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"; import ProgressBarPlugin from "progress-bar-webpack-plugin"; import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; @@ -59,20 +58,7 @@ export function webpackLensRenderer({ showVars = true } = {}): webpack.Configura ] }, optimization: { - minimize: isProduction, - minimizer: [ - new TerserPlugin({ - cache: true, - parallel: true, - sourceMap: true, - extractComments: { - condition: "some", - banner: [ - `OpenLens - Open Source Kubernetes IDE. Copyright ${new Date().getFullYear()} OpenLens Authors` - ].join("\n") - } - }) - ], + minimize: false }, module: { diff --git a/yarn.lock b/yarn.lock index cddedb6c2e..f1704da1c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -925,13 +925,6 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" -"@npmcli/move-file@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464" - integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw== - dependencies: - mkdirp "^1.0.4" - "@panva/asn1.js@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6" @@ -1769,14 +1762,6 @@ dependencies: tempy "*" -"@types/terser-webpack-plugin@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/terser-webpack-plugin/-/terser-webpack-plugin-3.0.0.tgz#8c5781922ce60611037b28186baf192e28780a03" - integrity sha512-K5C7izOT8rR4qiE2vfXcQNEJN4lT9cq/2qJgpMUWR2HsjDW/KVrHx2CaHuaXvaqDNsRmdELPLaxeJHiI4GjVrA== - dependencies: - "@types/webpack" "*" - terser "^4.6.13" - "@types/testing-library__jest-dom@^5.9.1": version "5.9.5" resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0" @@ -3296,29 +3281,6 @@ cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3: unique-filename "^1.1.1" y18n "^4.0.0" -cacache@^15.0.4: - version "15.0.4" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.4.tgz#b2c23cf4ac4f5ead004fb15a0efb0a20340741f1" - integrity sha512-YlnKQqTbD/6iyoJvEY3KJftjrdBYroCbxxYXzhOzsFLWlp6KX4BOlEf4mTx0cMUfVaTS3ENL2QtDWeRYoGLkkw== - dependencies: - "@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 "^5.1.1" - 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.0" - tar "^6.0.2" - unique-filename "^1.1.1" - cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -5949,15 +5911,6 @@ find-cache-dir@^2.1.0: make-dir "^2.0.0" pkg-dir "^3.0.0" -find-cache-dir@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" - integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - find-npm-prefix@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/find-npm-prefix/-/find-npm-prefix-1.0.2.tgz#8d8ce2c78b3b4b9e66c8acc6a37c231eb841cfdf" @@ -9084,7 +9037,7 @@ make-dir@^2.0.0: pify "^4.0.1" semver "^5.6.0" -make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: +make-dir@^3.0.0, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -9417,27 +9370,6 @@ minimist@~0.0.1: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= -minipass-collect@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" - integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== - dependencies: - minipass "^3.0.0" - -minipass-flush@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" - integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== - dependencies: - minipass "^3.0.0" - -minipass-pipeline@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.3.tgz#55f7839307d74859d6e8ada9c3ebe72cec216a34" - integrity sha512-cFOknTvng5vqnwOpDsZTWhNll6Jf8o2x+/diplafmxpuIymAjzoOolZG0VvQf3V2HgqzJNhnuKHYp2BqDgz8IQ== - dependencies: - minipass "^3.0.0" - minipass@^2.3.5, minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" @@ -9446,7 +9378,7 @@ minipass@^2.3.5, minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: safe-buffer "^5.1.2" yallist "^3.0.0" -minipass@^3.0.0, minipass@^3.1.1: +minipass@^3.0.0: version "3.1.3" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== @@ -9460,14 +9392,6 @@ minizlib@^1.2.1: dependencies: minipass "^2.9.0" -minizlib@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.0.tgz#fd52c645301ef09a63a2c209697c294c6ce02cf3" - integrity sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - minizlib@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -9505,7 +9429,7 @@ mkdirp-classic@^0.5.2: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@1.x, mkdirp@^1.0.3, mkdirp@^1.0.4: +mkdirp@1.x, mkdirp@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== @@ -10620,7 +10544,7 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" -p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0: +p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -10660,13 +10584,6 @@ p-map@^2.0.0: resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - p-retry@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" @@ -11031,7 +10948,7 @@ pkg-dir@^3.0.0: dependencies: find-up "^3.0.0" -pkg-dir@^4.1.0, pkg-dir@^4.2.0: +pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== @@ -12969,13 +12886,6 @@ ssri@^6.0.0, ssri@^6.0.1: dependencies: figgy-pudding "^3.5.1" -ssri@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808" - integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA== - dependencies: - minipass "^3.1.1" - stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" @@ -13438,18 +13348,6 @@ tar@^4.4.10, tar@^4.4.12, tar@^4.4.13: safe-buffer "^5.1.2" yallist "^3.0.3" -tar@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39" - integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^3.0.0" - minizlib "^2.1.0" - mkdirp "^1.0.3" - yallist "^4.0.0" - tar@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" @@ -13528,22 +13426,7 @@ terser-webpack-plugin@^1.4.3: webpack-sources "^1.4.0" worker-farm "^1.7.0" -terser-webpack-plugin@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-3.0.3.tgz#23bda2687b197f878a743373b9411d917adc2e45" - integrity sha512-bZFnotuIKq5Rqzrs+qIwFzGdKdffV9epG5vDSEbYzvKAhPeR5RbbrQysfPgbIIMhNAQtZD2hGwBfSKUXjXZZZw== - dependencies: - cacache "^15.0.4" - find-cache-dir "^3.3.1" - jest-worker "^26.0.0" - p-limit "^2.3.0" - schema-utils "^2.6.6" - serialize-javascript "^3.1.0" - source-map "^0.6.1" - terser "^4.6.13" - webpack-sources "^1.4.3" - -terser@^4.1.2, terser@^4.6.13, terser@^4.6.3: +terser@^4.1.2, terser@^4.6.3: version "4.7.0" resolved "https://registry.yarnpkg.com/terser/-/terser-4.7.0.tgz#15852cf1a08e3256a80428e865a2fa893ffba006" integrity sha512-Lfb0RiZcjRDXCC3OSHJpEkxJ9Qeqs6mp2v4jf2MHfy8vGERmVDuvjXdd/EnP5Deme5F2yBRBymKmKHCBg2echw== @@ -14540,7 +14423,7 @@ webpack-node-externals@^1.7.2: resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz#6e1ee79ac67c070402ba700ef033a9b8d52ac4e3" integrity sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg== -webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== From 0ae5e948c5ed56546c8e05ec55ed96e08fa1c237 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 26 Apr 2021 16:42:59 +0300 Subject: [PATCH 2/8] Release v5.0.0-alpha.3 (#2623) * v5.0.0-alpha.3 Signed-off-by: Jari Kolehmainen * fix Signed-off-by: Jari Kolehmainen --- .azure-pipelines.yml | 3 +++ Makefile | 1 + build/set_build_version.ts | 23 +++++++++++++++++++++++ package.json | 3 ++- static/RELEASE_NOTES.md | 2 +- 5 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 build/set_build_version.ts diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 5c062f2935..76e64f0f2c 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -62,6 +62,7 @@ jobs: WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD) AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) + BUILD_NUMBER: $(Build.BuildNumber) - job: macOS pool: vmImage: macOS-10.14 @@ -113,6 +114,7 @@ jobs: CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD) AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) + BUILD_NUMBER: $(Build.BuildNumber) - job: Linux pool: vmImage: ubuntu-16.04 @@ -170,6 +172,7 @@ jobs: env: AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) + BUILD_NUMBER: $(Build.BuildNumber) - script: make publish-npm displayName: Publish npm package condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" diff --git a/Makefile b/Makefile index d8a6a35462..e548232aa9 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,7 @@ integration-win: binaries/client build-extension-types build-extensions .PHONY: build build: node_modules binaries/client build-extensions + yarn run npm:fix-build-version yarn run compile ifeq "$(DETECTED_OS)" "Windows" yarn run electron-builder --publish onTag --x64 --ia32 diff --git a/build/set_build_version.ts b/build/set_build_version.ts new file mode 100644 index 0000000000..c18abbca9c --- /dev/null +++ b/build/set_build_version.ts @@ -0,0 +1,23 @@ +import * as fs from "fs"; +import * as path from "path"; +import appInfo from "../package.json"; +import semver from "semver"; + +const packagePath = path.join(__dirname, "../package.json"); +const versionInfo = semver.parse(appInfo.version); +const buildNumber = process.env.BUILD_NUMBER || "1"; +let buildChannel = "alpha"; + +if (versionInfo.prerelease) { + if (versionInfo.prerelease.includes("alpha")) { + buildChannel = "alpha"; + } else { + buildChannel = "beta"; + } + appInfo.version = `${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}-${buildChannel}.${versionInfo.prerelease[1]}.${buildNumber}`; +} else { + appInfo.version = `${appInfo.version}-latest.${buildNumber}`; +} + + +fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`); diff --git a/package.json b/package.json index a9bea801ad..48628cfa29 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-lens", "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", - "version": "5.0.0-alpha.2", + "version": "5.0.0-alpha.3", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", @@ -21,6 +21,7 @@ "compile:main": "yarn run webpack --config webpack.main.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts", "compile:extension-types": "yarn run webpack --config webpack.extensions.ts", + "npm:fix-build-version": "yarn run ts-node build/set_build_version.ts", "npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts", "build:linux": "yarn run compile && electron-builder --linux --dir", "build:mac": "yarn run compile && electron-builder --mac --dir", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 76835712ee..11033ac98e 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,7 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 5.0.0-alpha.2 (current version) +## 5.0.0-alpha.3 (current version) - Workspaces are replaced by Catalog & Hotbar - YAML Templates in Create Resource dock tab From 7fde8125ebec6c639464ab2fb1e101034c8fb0f2 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 26 Apr 2021 17:01:39 +0300 Subject: [PATCH 3/8] Helm 3.5.4 (#2619) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 48628cfa29..31c39192a4 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "config": { "bundledKubectlVersion": "1.18.15", - "bundledHelmVersion": "3.4.2" + "bundledHelmVersion": "3.5.4" }, "engines": { "node": ">=12 <13" From 1f854d0a0f7c3fa35a8f0c795b97bebe8cc548a1 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 27 Apr 2021 01:11:50 -0400 Subject: [PATCH 4/8] Cherry-pick from 4.2.3 (#2628) * Fix: logs data disapearing causing crashes (#2566) Signed-off-by: Sebastian Malton * Refactor helm-chart.api and improve kube validation and error handling (#2265) Signed-off-by: Sebastian Malton * Fix: HPA's not sortable by age (#2565) Signed-off-by: Sebastian Malton * Conditionally render status icon for kube meta (#2298) Signed-off-by: Sebastian Malton * Fix custom resource loading spinner appears above extensions' cluster menus (#2344) Signed-off-by: Sebastian Malton * Lens should point to the release docs (#2268) Signed-off-by: Sebastian Malton * Refactor the Extensions settings page (#2221) Signed-off-by: Sebastian Malton * try and get jest to not core dump Signed-off-by: Sebastian Malton --- src/common/__tests__/cluster-store.test.ts | 6 +- src/common/base-store.ts | 6 +- src/common/cluster-store.ts | 27 +- src/common/ipc/ipc.ts | 8 +- src/common/protocol-handler/router.ts | 6 +- src/common/utils/disposer.ts | 20 + src/common/utils/downloadFile.ts | 15 +- src/common/utils/index.ts | 2 + src/common/utils/type-narrowing.ts | 82 +- src/common/vars.ts | 9 +- .../__tests__/extension-discovery.test.ts | 101 +- src/extensions/extension-discovery.ts | 163 ++- src/extensions/extension-loader.ts | 34 +- .../registries/kube-object-status-registry.ts | 14 +- src/main/logger.ts | 4 +- .../api/__tests__/kube-object.test.ts | 228 +++++ src/renderer/api/endpoints/helm-charts.api.ts | 70 +- src/renderer/api/json-api.ts | 67 +- src/renderer/api/kube-api.ts | 215 ++-- src/renderer/api/kube-json-api.ts | 42 +- src/renderer/api/kube-object.ts | 57 +- src/renderer/bootstrap.tsx | 2 + .../+apps-helm-charts/helm-chart-details.tsx | 31 +- .../+apps-helm-charts/helm-chart.store.ts | 18 +- .../components/+config-autoscalers/hpa.tsx | 3 +- .../+extensions/__tests__/extensions.test.tsx | 104 +- .../+extensions/extension-install.store.ts | 218 ++++ .../components/+extensions/extensions.tsx | 939 ++++++++++-------- src/renderer/components/button/button.tsx | 22 +- .../confirm-dialog/confirm-dialog.tsx | 40 +- .../components/dock/install-chart.store.ts | 6 +- src/renderer/components/dock/log-controls.tsx | 7 +- src/renderer/components/dock/logs.tsx | 34 +- src/renderer/components/input/input.tsx | 1 + .../components/input/input_validators.ts | 8 + .../kube-object-status-icon.tsx | 143 +-- .../kube-object/kube-object-meta.tsx | 13 +- src/renderer/components/layout/sidebar.tsx | 13 +- src/renderer/components/spinner/spinner.scss | 6 - src/renderer/components/spinner/spinner.tsx | 5 +- src/renderer/protocol-handler/app-handlers.ts | 19 +- src/renderer/utils/cancelableFetch.ts | 36 - 42 files changed, 1777 insertions(+), 1067 deletions(-) create mode 100644 src/common/utils/disposer.ts create mode 100644 src/renderer/api/__tests__/kube-object.test.ts create mode 100644 src/renderer/components/+extensions/extension-install.store.ts delete mode 100644 src/renderer/utils/cancelableFetch.ts diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 2dc36b795c..6fb5f40659 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -43,13 +43,17 @@ jest.mock("electron", () => { }, ipcMain: { handle: jest.fn(), - on: jest.fn() + on: jest.fn(), + removeAllListeners: jest.fn(), + off: jest.fn(), + send: jest.fn(), } }; }); describe("empty config", () => { beforeEach(async () => { + ClusterStore.getInstance(false)?.unregisterIpcListener(); ClusterStore.resetInstance(); const mockOpts = { "tmp": { diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 91d3ef8312..4597059dca 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -124,8 +124,8 @@ export abstract class BaseStore extends Singleton { } unregisterIpcListener() { - ipcRenderer.removeAllListeners(this.syncMainChannel); - ipcRenderer.removeAllListeners(this.syncRendererChannel); + ipcRenderer?.removeAllListeners(this.syncMainChannel); + ipcRenderer?.removeAllListeners(this.syncRendererChannel); } disableSync() { @@ -167,7 +167,7 @@ export abstract class BaseStore extends Singleton { /** * toJSON is called when syncing the store to the filesystem. It should - * produce a JSON serializable object representaion of the current state. + * produce a JSON serializable object representation of the current state. * * It is recommended that a round trip is valid. Namely, calling * `this.fromStore(this.toJSON())` shouldn't change the state. diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 56deb35758..57cf900f57 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,5 +1,5 @@ import path from "path"; -import { app, ipcRenderer, remote, webFrame } from "electron"; +import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron"; import { unlink } from "fs-extra"; import { action, comparer, computed, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; @@ -12,6 +12,7 @@ import { saveToAppFiles } from "./utils/saveToAppFiles"; import { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting"; +import { disposer, noop } from "./utils"; export interface ClusterIconUpload { clusterId: string; @@ -111,6 +112,7 @@ export class ClusterStore extends BaseStore { @observable clusters = observable.map(); private static stateRequestChannel = "cluster:states"; + protected disposer = disposer(); constructor() { super({ @@ -143,7 +145,7 @@ export class ClusterStore extends BaseStore { cluster.setState(clusterState.state); } }); - } else { + } else if (ipcMain) { handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { const states: clusterStateSync[] = []; @@ -160,13 +162,16 @@ export class ClusterStore extends BaseStore { } protected pushStateToViewsAutomatically() { - if (!ipcRenderer) { - reaction(() => this.enabledClustersList, () => { - this.pushState(); - }); - reaction(() => this.connectedClustersList, () => { - this.pushState(); - }); + if (ipcMain) { + this.disposer.push( + reaction(() => this.enabledClustersList, () => { + this.pushState(); + }), + reaction(() => this.connectedClustersList, () => { + this.pushState(); + }), + () => unsubscribeAllFromBroadcast("cluster:state"), + ); } } @@ -180,7 +185,7 @@ export class ClusterStore extends BaseStore { unregisterIpcListener() { super.unregisterIpcListener(); - unsubscribeAllFromBroadcast("cluster:state"); + this.disposer(); } pushState() { @@ -288,7 +293,7 @@ export class ClusterStore extends BaseStore { // remove only custom kubeconfigs (pasted as text) if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { - unlink(cluster.kubeConfigPath).catch(() => null); + await unlink(cluster.kubeConfigPath).catch(noop); } } } diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index b104b31f4a..ebb3520fa3 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -28,7 +28,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { if (ipcRenderer) { ipcRenderer.send(channel, ...args); - } else { + } else if (ipcMain) { ipcMain.emit(channel, ...args); } @@ -55,7 +55,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) { if (ipcRenderer) { ipcRenderer.on(channel, listener); - } else { + } else if (ipcMain) { ipcMain.on(channel, listener); } @@ -65,7 +65,7 @@ export function subscribeToBroadcast(channel: string, listener: (...args: any[]) export function unsubscribeFromBroadcast(channel: string, listener: (...args: any[]) => any) { if (ipcRenderer) { ipcRenderer.off(channel, listener); - } else { + } else if (ipcMain) { ipcMain.off(channel, listener); } } @@ -73,7 +73,7 @@ export function unsubscribeFromBroadcast(channel: string, listener: (...args: an export function unsubscribeAllFromBroadcast(channel: string) { if (ipcRenderer) { ipcRenderer.removeAllListeners(channel); - } else { + } else if (ipcMain) { ipcMain.removeAllListeners(channel); } } diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 80c2076cd3..7c2399e81f 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; * Though under the current (2021/01/18) implementation, these are never matched * against in the final matching so their names are less of a concern. */ -const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; -const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; +export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; +export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; export abstract class LensProtocolRouter extends Singleton { // Map between path schemas and the handlers @@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton { public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; - protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; /** * diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts new file mode 100644 index 0000000000..5e26bcd0e1 --- /dev/null +++ b/src/common/utils/disposer.ts @@ -0,0 +1,20 @@ +export type Disposer = () => void; + +interface Extendable { + push(...vals: T[]): void; +} + +export type ExtendableDisposer = Disposer & Extendable; + +export function disposer(...args: Disposer[]): ExtendableDisposer { + const res = () => { + args.forEach(dispose => dispose?.()); + args.length = 0; + }; + + res.push = (...vals: Disposer[]) => { + args.push(...vals); + }; + + return res; +} diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index dfa549da07..cd01db29ac 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -6,13 +6,13 @@ export interface DownloadFileOptions { timeout?: number; } -export interface DownloadFileTicket { +export interface DownloadFileTicket { url: string; - promise: Promise; + promise: Promise; cancel(): void; } -export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { +export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { const fileChunks: Buffer[] = []; const req = request(url, { gzip, timeout }); const promise: Promise = new Promise((resolve, reject) => { @@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) } }; } + +export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { + const { promise, ...rest } = downloadFile(args); + + return { + promise: promise.then(res => JSON.parse(res.toString())), + ...rest + }; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 16a077277e..7ed9fc9d05 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -19,6 +19,8 @@ export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; export * from "./type-narrowing"; +export * from "./disposer"; + import * as iter from "./iter"; export { iter }; diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index 6a239c43ee..9cfe6934c5 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -1,13 +1,89 @@ /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested - * @param key The key to test if it is present on the object + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) */ -export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) { +export function hasOwnProperty(val: S, key: K): val is (S & { [key in K]: unknown }) { // this call syntax is for when `val` was created by `Object.create(null)` return Object.prototype.hasOwnProperty.call(val, key); } -export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { +/** + * Narrows `val` to a static type that includes fields of names in `keys` + * @param val the value that we are trying to type narrow + * @param keys the key names (must be literals for tsc to do any meaningful typing) + */ +export function hasOwnProperties(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) { return keys.every(key => hasOwnProperty(val, key)); } + +/** + * Narrows `val` to include the property `key` with type `V` + * @param val the value that we are trying to type narrow + * @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 is valid + */ +export function hasTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) { + return hasOwnProperty(val, key) && isValid(val[key]); +} + +/** + * Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it + * @param val the value that we are trying to type narrow + * @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 }) { + if (hasOwnProperty(val, key)) { + return typeof val[key] === "undefined" || isValid(val[key]); + } + + return true; +} + +/** + * isRecord checks if `val` matches the signature `Record` or `{ [label in T]: V }` + * @param val The value to be checked + * @param isKey a function for checking if the key is of the correct type + * @param isValue a function for checking if a value is of the correct type + */ +export function isRecord(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record { + return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value)); +} + +/** + * isTypedArray checks if `val` is an array and all of its entries are of type `T` + * @param val The value to be checked + * @param isEntry a function for checking if an entry is the correct type + */ +export function isTypedArray(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] { + return Array.isArray(val) && val.every(isEntry); +} + +/** + * checks if val is of type string + * @param val the value to be checked + */ +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +/** + * checks if val is of type object and isn't null + * @param val the value to be checked + */ +export function isObject(val: unknown): val is object { + return typeof val === "object" && val !== 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. + * + * This is useful for when using `hasOptionalProperty` and `hasTypedProperty` + * @param fn A typescript user predicate function to be bound + * @param boundArgs the set of arguments to be passed to `fn` in the new function + */ +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); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index e30c050a02..03d3f1c63d 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -1,5 +1,6 @@ // App's common configuration for any process (main, renderer, build pipeline, etc.) import path from "path"; +import { SemVer } from "semver"; import packageInfo from "../../package.json"; import { defineGlobal } from "./utils/defineGlobal"; @@ -44,5 +45,11 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; -export const docsUrl = "https://docs.k8slens.dev/"; export const supportUrl = "https://docs.k8slens.dev/latest/support/"; + +// This explicitly ignores the prerelease info on the package version +const { major, minor, patch } = new SemVer(packageInfo.version); +const mmpVersion = [major, minor, patch].join("."); +const docsVersion = isProduction ? `v${mmpVersion}` : "latest"; + +export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`; diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index c4775f29fe..d07dfe975c 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -1,10 +1,14 @@ +import mockFs from "mock-fs"; import { watch } from "chokidar"; -import { join, normalize } from "path"; -import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery"; import { ExtensionsStore } from "../extensions-store"; +import path from "path"; +import { ExtensionDiscovery } from "../extension-discovery"; +import os from "os"; +import { Console } from "console"; + +jest.setTimeout(60_000); jest.mock("../../common/ipc"); -jest.mock("fs-extra"); jest.mock("chokidar", () => ({ watch: jest.fn() })); @@ -15,6 +19,7 @@ jest.mock("../extension-installer", () => ({ } })); +console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; describe("ExtensionDiscovery", () => { @@ -24,47 +29,59 @@ describe("ExtensionDiscovery", () => { ExtensionsStore.createInstance(); }); - it("emits add for added extension", async done => { - globalThis.__non_webpack_require__.mockImplementation(() => ({ - name: "my-extension" - })); - let addHandler: (filePath: string) => void; - - const mockWatchInstance: any = { - on: jest.fn((event: string, handler: typeof addHandler) => { - if (event === "add") { - addHandler = handler; - } - - return mockWatchInstance; - }) - }; - - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any - ); - const extensionDiscovery = ExtensionDiscovery.createInstance(); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - extensionDiscovery.events.on("add", (extension: InstalledExtension) => { - expect(extension).toEqual({ - absolutePath: expect.any(String), - id: normalize("node_modules/my-extension/package.json"), - isBundled: false, - isEnabled: false, - manifest: { - name: "my-extension", - }, - manifestPath: normalize("node_modules/my-extension/package.json"), + describe("with mockFs", () => { + beforeEach(() => { + mockFs({ + [`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({ + name: "my-extension" + }), }); - done(); }); - addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + afterEach(() => { + mockFs.restore(); + }); + + it("emits add for added extension", async (done) => { + let addHandler: (filePath: string) => void; + + const mockWatchInstance: any = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }) + }; + + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any + ); + + const extensionDiscovery = ExtensionDiscovery.createInstance(); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + extensionDiscovery.events.on("add", extension => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: path.normalize("node_modules/my-extension/package.json"), + isBundled: false, + isEnabled: false, + manifest: { + name: "my-extension", + }, + manifestPath: path.normalize("node_modules/my-extension/package.json"), + }); + done(); + }); + + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + }); }); it("doesn't emit add for added file under extension", async done => { @@ -94,7 +111,7 @@ describe("ExtensionDiscovery", () => { extensionDiscovery.events.on("add", onAdd); - addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); setTimeout(() => { expect(onAdd).not.toHaveBeenCalled(); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 2ed8f377f3..4b08631b07 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -1,31 +1,33 @@ import { watch } from "chokidar"; import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; -import fs from "fs-extra"; +import fse from "fs-extra"; import { observable, reaction, toJS, when } from "mobx"; import os from "os"; import path from "path"; import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; import { Singleton } from "../common/utils"; import logger from "../main/logger"; +import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store"; import { extensionInstaller, PackageJson } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; +import { ExtensionLoader } from "./extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { - id: LensExtensionId; + id: LensExtensionId; - readonly manifest: LensExtensionManifest; + readonly manifest: LensExtensionManifest; - // Absolute path to the non-symlinked source folder, - // e.g. "/Users/user/.k8slens/extensions/helloworld" - readonly absolutePath: string; + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; - // Absolute to the symlinked package.json file - readonly manifestPath: string; - readonly isBundled: boolean; // defined in project root's package.json - isEnabled: boolean; - } + // Absolute to the symlinked package.json file + readonly manifestPath: string; + readonly isBundled: boolean; // defined in project root's package.json + isEnabled: boolean; +} const logModule = "[EXTENSION-DISCOVERY]"; @@ -39,7 +41,7 @@ interface ExtensionDiscoveryChannelMessage { * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * @param lstat the stats to compare */ -const isDirectoryLike = (lstat: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); +const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); /** * Discovers installed bundled and local extensions from the filesystem. @@ -64,12 +66,7 @@ export class ExtensionDiscovery extends Singleton { // IPC channel to broadcast changes to extension-discovery from main protected static readonly extensionDiscoveryChannel = "extension-discovery:main"; - public events: EventEmitter; - - constructor() { - super(); - this.events = new EventEmitter(); - } + public events = new EventEmitter(); get localFolderPath(): string { return path.join(os.homedir(), ".k8slens", "extensions"); @@ -146,8 +143,10 @@ export class ExtensionDiscovery extends Singleton { }) // Extension add is detected by watching "/package.json" add .on("add", this.handleWatchFileAdd) - // Extension remove is detected by watching " unlink - .on("unlinkDir", this.handleWatchUnlinkDir); + // Extension remove is detected by watching "" unlink + .on("unlinkDir", this.handleWatchUnlinkEvent) + // Extension remove is detected by watching "" unlink + .on("unlink", this.handleWatchUnlinkEvent); } handleWatchFileAdd = async (manifestPath: string) => { @@ -161,6 +160,7 @@ export class ExtensionDiscovery extends Singleton { if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) { try { + ExtensionInstallationStateStore.setInstallingFromMain(manifestPath); const absPath = path.dirname(manifestPath); // this.loadExtensionFromPath updates this.packagesJson @@ -168,7 +168,7 @@ export class ExtensionDiscovery extends Singleton { if (extension) { // Remove a broken symlink left by a previous installation if it exists. - await this.removeSymlinkByManifestPath(manifestPath); + await fse.remove(extension.manifestPath); // Install dependencies for the new extension await this.installPackage(extension.absolutePath); @@ -178,40 +178,46 @@ export class ExtensionDiscovery extends Singleton { this.events.emit("add", extension); } } catch (error) { - console.error(error); + logger.error(`${logModule}: failed to add extension: ${error}`, { error }); + } finally { + ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath); } } }; - handleWatchUnlinkDir = async (filePath: string) => { - // filePath is the non-symlinked path to the extension folder - // this.packagesJson.dependencies value is the non-symlinked path to the extension folder - // LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file - + /** + * Handle any unlink event, filtering out non-package.json links so the delete code + * only happens once per extension. + * @param filePath The absolute path to either a folder or file in the extensions folder + */ + handleWatchUnlinkEvent = async (filePath: string): Promise => { // Check that the removed path is directly under this.localFolderPath // Note that the watcher can create unlink events for subdirectories of the extension const extensionFolderName = path.basename(filePath); + const expectedPath = path.relative(this.localFolderPath, filePath); - if (path.relative(this.localFolderPath, filePath) === extensionFolderName) { - const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath); - - if (extension) { - const extensionName = extension.manifest.name; - - // If the extension is deleted manually while the application is running, also remove the symlink - await this.removeSymlinkByPackageName(extensionName); - - // The path to the manifest file is the lens extension id - // Note that we need to use the symlinked path - const lensExtensionId = extension.manifestPath; - - this.extensions.delete(extension.id); - logger.info(`${logModule} removed extension ${extensionName}`); - this.events.emit("remove", lensExtensionId as LensExtensionId); - } else { - logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); - } + if (expectedPath !== extensionFolderName) { + return; } + + const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath); + + if (!extension) { + return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); + } + + const extensionName = extension.manifest.name; + + // If the extension is deleted manually while the application is running, also remove the symlink + await this.removeSymlinkByPackageName(extensionName); + + // The path to the manifest file is the lens extension id + // Note: that we need to use the symlinked path + const lensExtensionId = extension.manifestPath; + + this.extensions.delete(extension.id); + logger.info(`${logModule} removed extension ${extensionName}`); + this.events.emit("remove", lensExtensionId); }; /** @@ -221,31 +227,23 @@ export class ExtensionDiscovery extends Singleton { * @param name e.g. "@mirantis/lens-extension-cc" */ removeSymlinkByPackageName(name: string) { - return fs.remove(this.getInstalledPath(name)); - } - - /** - * Remove the symlink under node_modules if it exists. - * @param manifestPath Path to package.json - */ - removeSymlinkByManifestPath(manifestPath: string) { - const manifestJson = __non_webpack_require__(manifestPath); - - return this.removeSymlinkByPackageName(manifestJson.name); + return fse.remove(this.getInstalledPath(name)); } /** * Uninstalls extension. * The application will detect the folder unlink and remove the extension from the UI automatically. - * @param extension Extension to uninstall. + * @param extensionId The ID of the extension to uninstall. */ - async uninstallExtension({ absolutePath, manifest }: InstalledExtension) { + async uninstallExtension(extensionId: LensExtensionId) { + const { manifest, absolutePath } = this.extensions.get(extensionId) ?? ExtensionLoader.getInstance().getExtension(extensionId); + logger.info(`${logModule} Uninstalling ${manifest.name}`); await this.removeSymlinkByPackageName(manifest.name); // fs.remove does nothing if the path doesn't exist anymore - await fs.remove(absolutePath); + await fse.remove(absolutePath); } async load(): Promise> { @@ -259,12 +257,11 @@ export class ExtensionDiscovery extends Singleton { logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); // fs.remove won't throw if path is missing - await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); - + await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); try { // Verify write access to static/extensions, which is needed for symlinking - await fs.access(this.inTreeFolderPath, fs.constants.W_OK); + await fse.access(this.inTreeFolderPath, fse.constants.W_OK); // Set bundled folder path to static/extensions this.bundledFolderPath = this.inTreeFolderPath; @@ -273,20 +270,20 @@ export class ExtensionDiscovery extends Singleton { // The error can happen if there is read-only rights to static/extensions, which would fail symlinking. // Remove e.g. /Users//Library/Application Support/LensDev/extensions - await fs.remove(this.inTreeTargetPath); + await fse.remove(this.inTreeTargetPath); // Create folder e.g. /Users//Library/Application Support/LensDev/extensions - await fs.ensureDir(this.inTreeTargetPath); + await fse.ensureDir(this.inTreeTargetPath); // Copy static/extensions to e.g. /Users//Library/Application Support/LensDev/extensions - await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); + await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath); // Set bundled folder path to e.g. /Users//Library/Application Support/LensDev/extensions this.bundledFolderPath = this.inTreeTargetPath; } - await fs.ensureDir(this.nodeModulesPath); - await fs.ensureDir(this.localFolderPath); + await fse.ensureDir(this.nodeModulesPath); + await fse.ensureDir(this.localFolderPath); const extensions = await this.ensureExtensions(); @@ -315,30 +312,22 @@ export class ExtensionDiscovery extends Singleton { * Returns InstalledExtension from path to package.json file. * Also updates this.packagesJson. */ - protected async getByManifest(manifestPath: string, { isBundled = false }: { - isBundled?: boolean; - } = {}): Promise { - let manifestJson: LensExtensionManifest; - + protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { try { - // check manifest file for existence - fs.accessSync(manifestPath, fs.constants.F_OK); - - manifestJson = __non_webpack_require__(manifestPath); - const installedManifestPath = this.getInstalledManifestPath(manifestJson.name); - - const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); + const manifest = await fse.readJson(manifestPath); + const installedManifestPath = this.getInstalledManifestPath(manifest.name); + const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); return { id: installedManifestPath, absolutePath: path.dirname(manifestPath), manifestPath: installedManifestPath, - manifest: manifestJson, + manifest, isBundled, isEnabled }; } catch (error) { - logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson }); + logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`); return null; } @@ -352,7 +341,7 @@ export class ExtensionDiscovery extends Singleton { const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name)); for (const extension of userExtensions) { - if (await fs.pathExists(extension.manifestPath) === false) { + if (await fse.pathExists(extension.manifestPath) === false) { await this.installPackage(extension.absolutePath); } } @@ -383,7 +372,7 @@ export class ExtensionDiscovery extends Singleton { async loadBundledExtensions() { const extensions: InstalledExtension[] = []; const folderPath = this.bundledFolderPath; - const paths = await fs.readdir(folderPath); + const paths = await fse.readdir(folderPath); for (const fileName of paths) { const absPath = path.resolve(folderPath, fileName); @@ -400,7 +389,7 @@ export class ExtensionDiscovery extends Singleton { async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise { const extensions: InstalledExtension[] = []; - const paths = await fs.readdir(folderPath); + const paths = await fse.readdir(folderPath); for (const fileName of paths) { // do not allow to override bundled extensions @@ -410,11 +399,11 @@ export class ExtensionDiscovery extends Singleton { const absPath = path.resolve(folderPath, fileName); - if (!fs.existsSync(absPath)) { + if (!fse.existsSync(absPath)) { continue; } - const lstat = await fs.lstat(absPath); + const lstat = await fse.lstat(absPath); // skip non-directories if (!isDirectoryLike(lstat)) { diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 78dee4fd8d..4846dd7e82 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -13,8 +13,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ". import type { LensMainExtension } from "./lens-main-extension"; import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; -import fs from "fs"; - export function extensionPackagesRoot() { return path.join((app || remote.app).getPath("userData")); @@ -290,28 +288,20 @@ export class ExtensionLoader extends Singleton { }); } - protected requireExtension(extension: InstalledExtension): LensExtensionConstructor { - let extEntrypoint = ""; + protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null { + const entryPointName = ipcRenderer ? "renderer" : "main"; + const extRelativePath = extension.manifest[entryPointName]; + + if (!extRelativePath) { + return null; + } + + const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath)); try { - if (ipcRenderer && extension.manifest.renderer) { - extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer)); - } else if (!ipcRenderer && extension.manifest.main) { - extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main)); - } - - if (extEntrypoint !== "") { - if (!fs.existsSync(extEntrypoint)) { - console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`); - - return; - } - - return __non_webpack_require__(extEntrypoint).default; - } - } catch (err) { - console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension }); - console.trace(err); + return __non_webpack_require__(extAbsolutePath).default; + } catch (error) { + logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error }); } } diff --git a/src/extensions/registries/kube-object-status-registry.ts b/src/extensions/registries/kube-object-status-registry.ts index 5f7aab8d5d..3ca20f569a 100644 --- a/src/extensions/registries/kube-object-status-registry.ts +++ b/src/extensions/registries/kube-object-status-registry.ts @@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration { export class KubeObjectStatusRegistry extends BaseRegistry { getItemsForKind(kind: string, apiVersion: string) { - return this.getItems().filter((item) => { - return item.kind === kind && item.apiVersions.includes(apiVersion); - }); + return this.getItems() + .filter((item) => ( + item.kind === kind + && item.apiVersions.includes(apiVersion) + )); + } + + getItemsForObject(src: KubeObject) { + return this.getItemsForKind(src.kind, src.apiVersion) + .map(item => item.resolve(src)) + .filter(Boolean); } } diff --git a/src/main/logger.ts b/src/main/logger.ts index 0ddc7bb1f7..f39c7618ad 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -1,6 +1,6 @@ import { app, remote } from "electron"; import winston from "winston"; -import { isDebugging } from "../common/vars"; +import { isDebugging, isTestEnv } from "../common/vars"; const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info"; const consoleOptions: winston.transports.ConsoleTransportOptions = { @@ -23,7 +23,7 @@ const logger = winston.createLogger({ ), transports: [ new winston.transports.Console(consoleOptions), - new winston.transports.File(fileOptions), + ...(isTestEnv ? [] : [new winston.transports.File(fileOptions)]), ], }); diff --git a/src/renderer/api/__tests__/kube-object.test.ts b/src/renderer/api/__tests__/kube-object.test.ts new file mode 100644 index 0000000000..b3f84ea1ae --- /dev/null +++ b/src/renderer/api/__tests__/kube-object.test.ts @@ -0,0 +1,228 @@ +import { KubeObject } from "../kube-object"; + +describe("KubeObject", () => { + describe("isJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["apiVersion", { kind: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["metadata", { kind: "", apiVersion: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: {} }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: {} }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiData (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isJsonApiData(valid)).toBe(true); + }); + }); + + describe("isPartialJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept {}", () => { + expect(KubeObject.isPartialJsonApiData({})).toBe(true); + }); + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "" }], + ]; + + it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(true); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing non-top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid Partial (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isPartialJsonApiData(valid)).toBe(true); + }); + }); + + describe("isJsonApiDataList", () => { + function isAny(val: unknown): val is any { + return !Boolean(void val); + } + + function isNotAny(val: unknown): val is any { + return Boolean(void val); + } + + function isBoolean(val: unknown): val is Boolean { + return typeof val === "boolean"; + } + + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "" }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 } }], + ["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => { + const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }; + + expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true); + }); + }); +}); diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 093adf9aef..9d4b5d4575 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: { name?: string; }) => string; -export const helmChartsApi = { - list() { - return apiBase - .get(endpoint()) - .then(data => { - return Object - .values(data) - .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) - .map(([chart]) => HelmChart.create(chart)); - }); - }, +/** + * Get a list of all helm charts from all saved helm repos + */ +export async function listCharts(): Promise { + const data = await apiBase.get(endpoint()); - get(repo: string, name: string, readmeVersion?: string) { - const path = endpoint({ repo, name }); + return Object + .values(data) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) + .map(([chart]) => HelmChart.create(chart)); +} - return apiBase - .get(`${path}?${stringify({ version: readmeVersion })}`) - .then(data => { - const versions = data.versions.map(HelmChart.create); - const readme = data.readme; +export interface GetChartDetailsOptions { + version?: string; + reqInit?: RequestInit; +} - return { - readme, - versions, - }; - }); - }, +/** + * Get the readme and all versions of a chart + * @param repo The repo to get from + * @param name The name of the chart to request the data of + * @param options.version The version of the chart's readme to get, default latest + * @param options.reqInit A way for passing in an abort controller or other browser request options + */ +export async function getChartDetails(repo: string, name: string, { version, reqInit }: GetChartDetailsOptions = {}): Promise { + const path = endpoint({ repo, name }); - getValues(repo: string, name: string, version: string) { - return apiBase - .get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); - } -}; + const { readme, ...data } = await apiBase.get(`${path}?${stringify({ version })}`, undefined, reqInit); + const versions = data.versions.map(HelmChart.create); + + return { + readme, + versions, + }; +} + +/** + * Get chart values related to a specific repos' version of a chart + * @param repo The repo to get from + * @param name The name of the chart to request the data of + * @param version The version to get the values from + */ +export async function getChartValues(repo: string, name: string, version: string): Promise { + return apiBase.get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); +} @autobind() export class HelmChart { diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index df12b08ab7..343aecd4c9 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -2,8 +2,8 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; -import { cancelableFetch } from "../utils/cancelableFetch"; import { randomBytes } from "crypto"; + export interface JsonApiData { } @@ -72,13 +72,11 @@ export class JsonApi { reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } - const infoLog: JsonApiLog = { + this.writeLog({ method: reqInit.method.toUpperCase(), reqUrl: reqPath, reqInit, - }; - - this.writeLog({ ...infoLog }); + }); return fetch(reqUrl, reqInit); } @@ -99,7 +97,7 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "delete" }); } - protected request(path: string, params?: P, init: RequestInit = {}) { + protected async request(path: string, params?: P, init: RequestInit = {}) { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; @@ -119,48 +117,53 @@ export class JsonApi { reqInit, }; - return cancelableFetch(reqUrl, reqInit).then(res => { - return this.parseResponse(res, infoLog); - }); + const res = await fetch(reqUrl, reqInit); + + return this.parseResponse(res, infoLog); } - protected parseResponse(res: Response, log: JsonApiLog): Promise { + protected async parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; - return res.text().then(text => { - let data; + const text = await res.text(); + let data; - try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body - } catch (e) { - data = text; - } + try { + data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } - if (status >= 200 && status < 300) { - this.onData.emit(data, res); - this.writeLog({ ...log, data }); + if (status >= 200 && status < 300) { + this.onData.emit(data, res); + this.writeLog({ ...log, data }); - return data; - } else if (log.method === "GET" && res.status === 403) { - this.writeLog({ ...log, data }); - } else { - const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + return data; + } - this.onError.emit(error, res); - this.writeLog({ ...log, error }); - throw error; - } - }); + if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, error: data }); + throw data; + } + + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + + throw error; } protected parseError(error: JsonApiError | string, res: Response): string[] { if (typeof error === "string") { return [error]; } - else if (Array.isArray(error.errors)) { + + if (Array.isArray(error.errors)) { return error.errors.map(error => error.title); } - else if (error.message) { + + if (error.message) { return [error.message]; } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 98833e3d4d..2c0739c6eb 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -7,11 +7,12 @@ import logger from "../../main/logger"; import { apiManager } from "./api-manager"; import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; import { IKubeWatchEvent } from "./kube-watch-api"; import { ReadableWebToNodeStream } from "../utils/readableStream"; +import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; +import { noop } from "../utils"; export interface IKubeApiOptions { /** @@ -34,6 +35,11 @@ export interface IKubeApiOptions { checkPreferredVersion?: boolean; } +export interface KubeApiListOptions { + namespace?: string; + reqInit?: RequestInit; +} + export interface IKubeApiQueryParams { watch?: boolean | number; resourceVersion?: string; @@ -245,7 +251,7 @@ export class KubeApi { return this.resourceVersions.get(namespace); } - async refreshResourceVersion(params?: { namespace: string }) { + async refreshResourceVersion(params?: KubeApiListOptions) { return this.list(params, { limit: 1 }); } @@ -273,20 +279,12 @@ export class KubeApi { return query; } - protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + protected parseResponse(data: unknown, namespace?: string): T | T[] | null { if (!data) return; const KubeObjectConstructor = this.objectConstructor; - if (KubeObject.isJsonApiData(data)) { - const object = new KubeObjectConstructor(data); - - ensureObjectSelfLink(this, object); - - return object; - } - - // process items list response - if (KubeObject.isJsonApiDataList(data)) { + // process items list response, check before single item since there is overlap + if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) { const { apiVersion, items, metadata } = data; this.setResourceVersion(namespace, metadata.resourceVersion); @@ -305,55 +303,90 @@ export class KubeApi { }); } + // process a single item + if (KubeObject.isJsonApiData(data)) { + const object = new KubeObjectConstructor(data); + + ensureObjectSelfLink(this, object); + + return object; + } + // 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; + return null; } - async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace }), { query }) - .then(data => this.parseResponse(data, namespace)); + const url = this.getUrl({ namespace }); + const res = await this.request.get(url, { query }, reqInit); + const parsed = this.parseResponse(res, namespace); + + if (Array.isArray(parsed)) { + return parsed; + } + + if (!parsed) { + return null; + } + + throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); } - async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { + async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace, name }), { query }) - .then(this.parseResponse); + const url = this.getUrl({ namespace, name }); + const res = await this.request.get(url, { query }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } - async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); + const apiUrl = this.getUrl({ namespace }); + const res = await this.request.post(apiUrl, { + data: merge({ + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace + } + }, data) + }); + const parsed = this.parseResponse(res); - return this.request - .post(apiUrl, { - data: merge({ - kind: this.kind, - apiVersion: this.apiVersionWithGroup, - metadata: { - name, - namespace - } - }, data) - }) - .then(this.parseResponse); + if (Array.isArray(parsed)) { + throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } - async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); - return this.request - .put(apiUrl, { data }) - .then(this.parseResponse); + const res = await this.request.put(apiUrl, { data }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } async delete({ name = "", namespace = "default" }) { @@ -372,78 +405,60 @@ export class KubeApi { } watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { - if (!opts.abortController) { - opts.abortController = new AbortController(); - } let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { abortController, namespace, callback } = opts; + const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts; - abortController.signal.addEventListener("abort", () => { + signal.addEventListener("abort", () => { clearTimeout(timedRetry); }); const watchUrl = this.getWatchUrl(namespace); - const responsePromise = this.request.getResponse(watchUrl, null, { - signal: abortController.signal - }); + const responsePromise = this.request.getResponse(watchUrl, null, { signal }); - responsePromise.then((response) => { - if (!response.ok && !abortController.signal.aborted) { - callback?.(null, response); - - return; - } - const nodeStream = new ReadableWebToNodeStream(response.body); - - ["end", "close", "error"].forEach((eventName) => { - nodeStream.on(eventName, () => { - if (errorReceived) return; // kubernetes errors should be handled in a callback - - clearTimeout(timedRetry); - timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry - if (abortController.signal.aborted) return; - - this.watch({...opts, namespace, callback}); - }, 1000); - }); - }); - - const stream = byline(nodeStream); - - stream.on("data", (line) => { - try { - const event: IKubeWatchEvent = JSON.parse(line); - - if (event.type === "ERROR" && event.object.kind === "Status") { - errorReceived = true; - callback(null, new KubeStatus(event.object as any)); - - return; - } - - this.modifyWatchEvent(event); - - if (callback) { - callback(event, null); - } - } catch (ignore) { - // ignore parse errors + responsePromise + .then(response => { + if (!response.ok) { + return callback(null, response); } + + const nodeStream = new ReadableWebToNodeStream(response.body); + + ["end", "close", "error"].forEach((eventName) => { + nodeStream.on(eventName, () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({...opts, namespace, callback}); + }, 1000); + }); + }); + + byline(nodeStream).on("data", (line) => { + try { + const event: IKubeWatchEvent = JSON.parse(line); + + if (event.type === "ERROR" && event.object.kind === "Status") { + errorReceived = true; + + return callback(null, new KubeStatus(event.object as any)); + } + + this.modifyWatchEvent(event); + callback(event, null); + } catch (ignore) { + // ignore parse errors + } + }); + }) + .catch(error => { + if (error instanceof DOMException) return; // AbortController rejects, we can ignore it + + callback(null, error); }); - }, (error) => { - if (error instanceof DOMException) return; // AbortController rejects, we can ignore it - callback?.(null, error); - }).catch((error) => { - callback?.(null, error); - }); - - const disposer = () => { - abortController.abort(); - }; - - return disposer; + return abort; } protected modifyWatchEvent(event: IKubeWatchEvent) { diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 362ee5438e..0dfe53c8d2 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -1,34 +1,38 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; +export interface KubeJsonApiListMetadata { + resourceVersion: string; + selfLink?: string; +} + export interface KubeJsonApiDataList { kind: string; apiVersion: string; items: T[]; - metadata: { - resourceVersion: string; - selfLink: string; + 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; }; } export interface KubeJsonApiData extends JsonApiData { kind: string; apiVersion: string; - metadata: { - uid: string; - name: string; - namespace?: string; - creationTimestamp?: string; - resourceVersion: string; - continue?: string; - finalizers?: string[]; - selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - }; + metadata: KubeJsonApiMetadata; } export interface KubeJsonApiError extends JsonApiError { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 7d0c34de33..8699a3c94f 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -1,12 +1,13 @@ // Base class for all kubernetes objects import moment from "moment"; -import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; +import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api"; import { autobind, formatDuration } from "../utils"; import { ItemObject } from "../item.store"; import { apiKube } from "./index"; import { JsonApiParams } from "./json-api"; import { resourceApplierApi } from "./endpoints/resource-applier.api"; +import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; export type IKubeObjectConstructor = (new (data: KubeJsonApiData | any) => T) & { kind?: string; @@ -78,15 +79,59 @@ export class KubeObject implements ItemObject { return !item.metadata.name.startsWith("system:"); } - static isJsonApiData(object: any): object is KubeJsonApiData { - return !object.items && object.metadata; + static isJsonApiData(object: unknown): object is KubeJsonApiData { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); } - static isJsonApiDataList(object: any): object is KubeJsonApiDataList { - return object.items && object.metadata; + static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + ); } - static stringifyLabels(labels: { [name: string]: string }): string[] { + static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "uid", isString) + && hasTypedProperty(object, "name", isString) + && hasTypedProperty(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)) + ); + } + + static isPartialJsonApiData(object: unknown): object is Partial { + return ( + isObject(object) + && hasOptionalProperty(object, "kind", isString) + && hasOptionalProperty(object, "apiVersion", isString) + && hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); + } + + static isJsonApiDataList(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata) + && hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem)) + ); + } + + static stringifyLabels(labels?: { [name: string]: string }): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index d923467939..8be97cc86d 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -20,6 +20,7 @@ import { App } from "./components/app"; import { LensApp } from "./lens-app"; import { ThemeStore } from "./theme.store"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; +import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; /** * If this is a development buid, wait a second to attach @@ -61,6 +62,7 @@ export async function bootstrap(App: AppComponent) { const themeStore = ThemeStore.createInstance(); const hotbarStore = HotbarStore.createInstance(); + ExtensionInstallationStateStore.bindIpcListeners(); HelmRepoManager.createInstance(); // initialize the manager // preload common stores diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index d31efc5438..e0dfb6c77e 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -1,14 +1,13 @@ import "./helm-chart-details.scss"; import React, { Component } from "react"; -import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { getChartDetails, HelmChart } from "../../api/endpoints/helm-charts.api"; import { observable, autorun } from "mobx"; import { observer } from "mobx-react"; import { Drawer, DrawerItem } from "../drawer"; import { autobind, stopPropagation } from "../../utils"; import { MarkdownViewer } from "../markdown-viewer"; import { Spinner } from "../spinner"; -import { CancelablePromise } from "../../utils/cancelableFetch"; import { Button } from "../button"; import { Select, SelectOption } from "../select"; import { createInstallChartTab } from "../dock/install-chart.store"; @@ -26,35 +25,37 @@ export class HelmChartDetails extends Component { @observable readme: string = null; @observable error: string = null; - private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; + private abortController?: AbortController; componentWillUnmount() { - this.chartPromise?.cancel(); + this.abortController?.abort(); } chartUpdater = autorun(() => { this.selectedChart = null; const { chart: { name, repo, version } } = this.props; - helmChartsApi.get(repo, name, version).then(result => { - this.readme = result.readme; - this.chartVersions = result.versions; - this.selectedChart = result.versions[0]; - }, - error => { - this.error = error; - }); + getChartDetails(repo, name, { version }) + .then(result => { + this.readme = result.readme; + this.chartVersions = result.versions; + this.selectedChart = result.versions[0]; + }) + .catch(error => { + this.error = error; + }); }); @autobind() - async onVersionChange({ value: version }: SelectOption) { + async onVersionChange({ value: version }: SelectOption) { this.selectedChart = this.chartVersions.find(chart => chart.version === version); this.readme = null; try { - this.chartPromise?.cancel(); + this.abortController?.abort(); + this.abortController = new AbortController(); const { chart: { name, repo } } = this.props; - const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version)); + const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }}); this.readme = readme; } catch (error) { diff --git a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts index 25559a9711..a5663e00cb 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts +++ b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts @@ -1,7 +1,7 @@ import semver from "semver"; import { observable } from "mobx"; import { autobind } from "../../utils"; -import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { getChartDetails, HelmChart, listCharts } from "../../api/endpoints/helm-charts.api"; import { ItemStore } from "../../item.store"; import flatten from "lodash/flatten"; @@ -16,7 +16,7 @@ export class HelmChartStore extends ItemStore { async loadAll() { try { - const res = await this.loadItems(() => helmChartsApi.list()); + const res = await this.loadItems(() => listCharts()); this.failedLoading = false; @@ -48,13 +48,13 @@ export class HelmChartStore extends ItemStore { return versions; } - const loadVersions = (repo: string) => { - return helmChartsApi.get(repo, chartName).then(({ versions }) => { - return versions.map(chart => ({ - repo, - version: chart.getVersion() - })); - }); + const loadVersions = async (repo: string) => { + const { versions } = await getChartDetails(repo, chartName); + + return versions.map(chart => ({ + repo, + version: chart.getVersion() + })); }; if (!this.isLoaded) { diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 2e2a78fc82..8bfae4ee12 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component { [columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), [columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), [columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), - [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() + [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas(), + [columnId.age]: (item: HorizontalPodAutoscaler) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: HorizontalPodAutoscaler) => item.getSearchFields() diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 6e56c112ca..714b31e290 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -1,16 +1,18 @@ import "@testing-library/jest-dom/extend-expect"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import fse from "fs-extra"; import React from "react"; import { UserStore } from "../../../../common/user-store"; import { ExtensionDiscovery } from "../../../../extensions/extension-discovery"; import { ExtensionLoader } from "../../../../extensions/extension-loader"; -import { ThemeStore } from "../../../theme.store"; import { ConfirmDialog } from "../../confirm-dialog"; -import { Notifications } from "../../notifications"; +import { ExtensionInstallationStateStore } from "../extension-install.store"; import { Extensions } from "../extensions"; +import mockFs from "mock-fs"; +jest.setTimeout(30000); jest.mock("fs-extra"); +jest.mock("../../notifications"); jest.mock("../../../../common/utils", () => ({ ...jest.requireActual("../../../../common/utils"), @@ -20,37 +22,30 @@ jest.mock("../../../../common/utils", () => ({ extractTar: jest.fn(() => Promise.resolve()) })); -jest.mock("../../notifications", () => ({ - ok: jest.fn(), - error: jest.fn(), - info: jest.fn() +jest.mock("electron", () => ({ + app: { + getVersion: () => "99.99.99", + getPath: () => "tmp", + getLocale: () => "en", + setLoginItemSettings: (): void => void 0, + } })); -jest.mock("electron", () => { - return { - app: { - getVersion: () => "99.99.99", - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: (): void => void 0, - } - }; -}); - describe("Extensions", () => { beforeEach(async () => { + mockFs({ + "tmp": {} + }); + + ExtensionInstallationStateStore.reset(); UserStore.resetInstance(); - ThemeStore.resetInstance(); await UserStore.createInstance().load(); - await ThemeStore.createInstance().init(); - ExtensionLoader.resetInstance(); ExtensionDiscovery.resetInstance(); - Extensions.installStates.clear(); - ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve()); + ExtensionLoader.resetInstance(); ExtensionLoader.createInstance().addExtension({ id: "extensionId", manifest: { @@ -64,49 +59,38 @@ describe("Extensions", () => { }); }); - it("disables uninstall and disable buttons while uninstalling", async () => { - ExtensionDiscovery.getInstance().isLoaded = true; - render(<>); - - expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); - - fireEvent.click(screen.getByText("Uninstall")); - - // Approve confirm dialog - fireEvent.click(screen.getByText("Yes")); - - expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled(); - expect(screen.getByText("Disable").closest("button")).toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); + afterEach(() => { + mockFs.restore(); }); - it("displays error notification on uninstall error", () => { + it("disables uninstall and disable buttons while uninstalling", async () => { ExtensionDiscovery.getInstance().isLoaded = true; - (ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() => - Promise.reject() - ); - render(<>); - expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); + const res = render(<>); - fireEvent.click(screen.getByText("Uninstall")); + expect(res.getByText("Disable").closest("button")).not.toBeDisabled(); + expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled(); + + fireEvent.click(res.getByText("Uninstall")); // Approve confirm dialog - fireEvent.click(screen.getByText("Yes")); + fireEvent.click(res.getByText("Yes")); - waitFor(() => { - expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); - expect(Notifications.error).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled(); + expect(res.getByText("Disable").closest("button")).toBeDisabled(); + expect(res.getByText("Uninstall").closest("button")).toBeDisabled(); + }, { + timeout: 30000, }); }); - it("disables install button while installing", () => { - render(); + it("disables install button while installing", async () => { + const res = render(); - fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", { + (fse.unlink as jest.MockedFunction).mockReturnValue(Promise.resolve() as any); + + fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", { exact: false }), { target: { @@ -114,13 +98,8 @@ describe("Extensions", () => { } }); - fireEvent.click(screen.getByText("Install")); - - waitFor(() => { - expect(screen.getByText("Install").closest("button")).toBeDisabled(); - expect(fse.move).toHaveBeenCalledWith(""); - expect(Notifications.error).not.toHaveBeenCalled(); - }); + fireEvent.click(res.getByText("Install")); + expect(res.getByText("Install").closest("button")).toBeDisabled(); }); it("displays spinner while extensions are loading", () => { @@ -128,8 +107,11 @@ describe("Extensions", () => { const { container } = render(); expect(container.querySelector(".Spinner")).toBeInTheDocument(); + }); + it("does not display the spinner while extensions are not loading", async () => { ExtensionDiscovery.getInstance().isLoaded = true; + const { container } = render(); waitFor(() => expect(container.querySelector(".Spinner")).not.toBeInTheDocument() diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts new file mode 100644 index 0000000000..787dc2b364 --- /dev/null +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -0,0 +1,218 @@ +import { action, computed, observable } from "mobx"; +import logger from "../../../main/logger"; +import { disposer, ExtendableDisposer } from "../../utils"; +import * as uuid from "uuid"; +import { broadcastMessage } from "../../../common/ipc"; +import { ipcRenderer } from "electron"; + +export enum ExtensionInstallationState { + INSTALLING = "installing", + UNINSTALLING = "uninstalling", + IDLE = "idle", +} + +const Prefix = "[ExtensionInstallationStore]"; + +export class ExtensionInstallationStateStore { + private static InstallingFromMainChannel = "extension-installation-state-store:install"; + private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install"; + private static PreInstallIds = observable.set(); + private static UninstallingExtensions = observable.set(); + private static InstallingExtensions = observable.set(); + + static bindIpcListeners() { + ipcRenderer + .on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => { + ExtensionInstallationStateStore.setInstalling(extId); + }) + .on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => { + ExtensionInstallationStateStore.clearInstalling(extId); + }); + } + + @action static reset() { + logger.warn(`${Prefix}: resetting, may throw errors`); + ExtensionInstallationStateStore.InstallingExtensions.clear(); + ExtensionInstallationStateStore.UninstallingExtensions.clear(); + ExtensionInstallationStateStore.PreInstallIds.clear(); + } + + /** + * Strictly transitions an extension from not installing to installing + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action static setInstalling(extId: string): void { + logger.debug(`${Prefix}: trying to set ${extId} as installing`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`); + } + + ExtensionInstallationStateStore.InstallingExtensions.add(extId); + } + + /** + * Broadcasts that an extension is being installed by the main process + * @param extId the ID of the extension + */ + static setInstallingFromMain(extId: string): void { + broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId); + } + + /** + * Broadcasts that an extension is no longer being installed by the main process + * @param extId the ID of the extension + */ + static clearInstallingFromMain(extId: string): void { + broadcastMessage(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, extId); + } + + /** + * Marks the start of a pre-install phase of an extension installation. The + * part of the installation before the tarball has been unpacked and the ID + * determined. + * @returns a disposer which should be called to mark the end of the install phase + */ + @action static startPreInstall(): ExtendableDisposer { + const preInstallStepId = uuid.v4(); + + logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`); + ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId); + + return disposer(() => { + ExtensionInstallationStateStore.PreInstallIds.delete(preInstallStepId); + logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`); + }); + } + + /** + * Strictly transitions an extension from not uninstalling to uninstalling + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action static setUninstalling(extId: string): void { + logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`); + } + + ExtensionInstallationStateStore.UninstallingExtensions.add(extId); + } + + /** + * Strictly clears the INSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not INSTALLING + */ + @action static clearInstalling(extId: string): void { + logger.debug(`${Prefix}: trying to clear ${extId} as installing`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.INSTALLING: + return void ExtensionInstallationStateStore.InstallingExtensions.delete(extId); + default: + throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`); + } + } + + /** + * Strictly clears the UNINSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not UNINSTALLING + */ + @action static clearUninstalling(extId: string): void { + logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.UNINSTALLING: + return void ExtensionInstallationStateStore.UninstallingExtensions.delete(extId); + default: + throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`); + } + } + + /** + * Returns the current state of the extension. IDLE is default value. + * @param extId The ID of the extension + */ + static getInstallationState(extId: string): ExtensionInstallationState { + if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) { + return ExtensionInstallationState.INSTALLING; + } + + if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) { + return ExtensionInstallationState.UNINSTALLING; + } + + return ExtensionInstallationState.IDLE; + } + + /** + * Returns true if the extension is currently INSTALLING + * @param extId The ID of the extension + */ + static isExtensionInstalling(extId: string): boolean { + return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING; + } + + /** + * Returns true if the extension is currently UNINSTALLING + * @param extId The ID of the extension + */ + static isExtensionUninstalling(extId: string): boolean { + return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING; + } + + /** + * Returns true if the extension is currently IDLE + * @param extId The ID of the extension + */ + static isExtensionIdle(extId: string): boolean { + return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE; + } + + /** + * The current number of extensions installing + */ + @computed static get installing(): number { + return ExtensionInstallationStateStore.InstallingExtensions.size; + } + + /** + * If there is at least one extension currently installing + */ + @computed static get anyInstalling(): boolean { + return ExtensionInstallationStateStore.installing > 0; + } + + /** + * The current number of extensions preinstalling + */ + @computed static get preinstalling(): number { + return ExtensionInstallationStateStore.PreInstallIds.size; + } + + /** + * If there is at least one extension currently downloading + */ + @computed static get anyPreinstalling(): boolean { + return ExtensionInstallationStateStore.preinstalling > 0; + } + + /** + * If there is at least one installing or preinstalling step taking place + */ + @computed static get anyPreInstallingOrInstalling(): boolean { + return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling; + } +} diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index b2202a6050..968f16c63e 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -1,15 +1,16 @@ +import "./extensions.scss"; import { remote, shell } from "electron"; import fse from "fs-extra"; -import { computed, observable, reaction } from "mobx"; +import { computed, observable, reaction, when } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; -import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils"; +import { autobind, disposer, Disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils"; import { docsUrl } from "../../../common/vars"; import { ExtensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery"; import { ExtensionLoader } from "../../../extensions/extension-loader"; -import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; +import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; import logger from "../../../main/logger"; import { prevDefault } from "../../utils"; import { Button } from "../button"; @@ -21,103 +22,447 @@ import { SubTitle } from "../layout/sub-title"; import { Notifications } from "../notifications"; import { Spinner } from "../spinner/spinner"; import { TooltipPosition } from "../tooltip"; -import "./extensions.scss"; +import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store"; +import URLParse from "url-parse"; +import { SemVer } from "semver"; +import _ from "lodash"; + +function getMessageFromError(error: any): string { + if (!error || typeof error !== "object") { + return "an error has occured"; + } + + if (error.message) { + return String(error.message); + } + + if (error.err) { + return String(error.err); + } + + const rawMessage = String(error); + + if (rawMessage === String({})) { + return "an error has occured"; + } + + return rawMessage; +} + +interface ExtensionInfo { + name: string; + version?: string; + requireConfirmation?: boolean; +} interface InstallRequest { fileName: string; - filePath?: string; - data?: Buffer; + dataP: Promise; } -interface InstallRequestPreloaded extends InstallRequest { +interface InstallRequestValidated { + fileName: string; data: Buffer; -} - -interface InstallRequestValidated extends InstallRequestPreloaded { + id: LensExtensionId; manifest: LensExtensionManifest; tempFile: string; // temp system path to packed extension for unpacking } -interface ExtensionState { - displayName: string; - // Possible states the extension can be - state: "installing" | "uninstalling"; +async function uninstallExtension(extensionId: LensExtensionId): Promise { + const loader = ExtensionLoader.getInstance(); + const { manifest } = loader.getExtension(extensionId); + const displayName = extensionDisplayName(manifest.name, manifest.version); + + try { + logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); + ExtensionInstallationStateStore.setUninstalling(extensionId); + + await ExtensionDiscovery.getInstance().uninstallExtension(extensionId); + + // wait for the ExtensionLoader to actually uninstall the extension + await when(() => !loader.userExtensions.has(extensionId)); + + 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); + } +} + +async function confirmUninstallExtension(extension: InstalledExtension): Promise { + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + const confirmed = await ConfirmDialog.confirm({ + message:

Are you sure you want to uninstall extension {displayName}?

, + labelOk: "Yes", + labelCancel: "No", + }); + + if (confirmed) { + await uninstallExtension(extension.id); + } +} + +function getExtensionDestFolder(name: string) { + return path.join(ExtensionDiscovery.getInstance().localFolderPath, sanitizeExtensionName(name)); +} + +function getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); +} + +async function readFileNotify(filePath: string, showError = true): Promise { + try { + return await fse.readFile(filePath); + } catch (error) { + if (showError) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error }); + Notifications.error(`Error while reading "${filePath}": ${message}`); + } + } + + return null; +} + +async function validatePackage(filePath: string): Promise { + const tarFiles = await listTarEntries(filePath); + + // tarball from npm contains single root folder "package/*" + const firstFile = tarFiles[0]; + + if(!firstFile) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const rootFolder = path.normalize(firstFile).split(path.sep)[0]; + const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); + const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; + + if(!tarFiles.includes(manifestLocation)) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + 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`); + } + + return manifest; +} + +async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise { + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); + + // validate packages + const tempFile = getExtensionPackageTemp(fileName); + + disposer.push(() => fse.unlink(tempFile)); + + try { + const data = await dataP; + + if (!data) { + return; + } + + await fse.writeFile(tempFile, data); + const manifest = await validatePackage(tempFile); + const id = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, manifest.name, "package.json"); + + return { + fileName, + data, + manifest, + tempFile, + id, + }; + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error }); + Notifications.error( +
+

Installing {fileName} has failed, skipping.

+

Reason: {message}

+
+ ); + } + + return null; +} + +async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) { + const { id, fileName, tempFile, manifest: { name, version } } = request; + + ExtensionInstallationStateStore.setInstalling(id); + disposeDownloading?.(); + + const displayName = extensionDisplayName(name, version); + const extensionFolder = getExtensionDestFolder(name); + const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); + + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + + try { + // extract to temp folder first + await fse.remove(unpackingTempFolder).catch(noop); + await fse.ensureDir(unpackingTempFolder); + await extractTar(tempFile, { cwd: unpackingTempFolder }); + + // move contents to extensions folder + const unpackedFiles = await fse.readdir(unpackingTempFolder); + let unpackedRootFolder = unpackingTempFolder; + + if (unpackedFiles.length === 1) { + // check if %extension.tgz was packed with single top folder, + // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball + unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); + } + + await fse.ensureDir(extensionFolder); + await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); + + // wait for the loader has actually install it + await when(() => ExtensionLoader.getInstance().userExtensions.has(id)); + + // Enable installed extensions by default. + ExtensionLoader.getInstance().userExtensions.get(id).isEnabled = true; + + Notifications.ok( +

Extension {displayName} successfully installed!

+ ); + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error }); + Notifications.error(

Installing extension {displayName} has failed: {message}

); + } finally { + // Remove install state once finished + ExtensionInstallationStateStore.clearInstalling(id); + + // clean up + fse.remove(unpackingTempFolder).catch(noop); + fse.unlink(tempFile).catch(noop); + } +} + +export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) { + const disposer = ExtensionInstallationStateStore.startPreInstall(); + const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString(); + const { promise } = downloadJson({ url: registryUrl }); + const json = await promise.catch(console.error); + + if (!json || json.error || typeof json.versions !== "object" || !json.versions) { + const message = json?.error ? `: ${json.error}` : ""; + + Notifications.error(`Failed to get registry information for that extension${message}`); + + return disposer(); + } + + if (version) { + if (!json.versions[version]) { + Notifications.error(

The {name} extension does not have a v{version}.

); + + return disposer(); + } + } else { + const versions = Object.keys(json.versions) + .map(version => new SemVer(version, { loose: true, includePrerelease: true })) + // ignore pre-releases for auto picking the version + .filter(version => version.prerelease.length === 0); + + version = _.reduce(versions, (prev, curr) => ( + prev.compareMain(curr) === -1 + ? curr + : prev + )).format(); + } + + if (requireConfirmation) { + const proceed = await ConfirmDialog.confirm({ + message:

Are you sure you want to install {name}@{version}?

, + labelCancel: "Cancel", + labelOk: "Install", + }); + + if (!proceed) { + return disposer(); + } + } + + const url = json.versions[version].dist.tarball; + const fileName = path.basename(url); + const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); + + return attemptInstall({ fileName, dataP }, disposer); +} + +async function attemptInstall(request: InstallRequest, d?: ExtendableDisposer): Promise { + const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); + const validatedRequest = await createTempFilesAndValidate(request, dispose); + + if (!validatedRequest) { + return dispose(); + } + + const { name, version, description } = validatedRequest.manifest; + const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id); + + if (curState !== ExtensionInstallationState.IDLE) { + dispose(); + + return Notifications.error( +
+ Extension Install Collision: +

The {name} extension is currently {curState.toLowerCase()}.

+

Will not proceed with this current install request.

+
+ ); + } + + const extensionFolder = getExtensionDestFolder(name); + const folderExists = await fse.pathExists(extensionFolder); + + if (!folderExists) { + // install extension if not yet exists + await unpackExtension(validatedRequest, dispose); + } else { + const { manifest: { version: oldVersion } } = ExtensionLoader.getInstance().getExtension(validatedRequest.id); + + // otherwise confirmation required (re-install / update) + const removeNotification = Notifications.info( +
+
+

Install extension {name}@{version}?

+

Description: {description}

+
shell.openPath(extensionFolder)}> + Warning: {name}@{oldVersion} will be removed before installation. +
+
+
, + { + onClose: dispose, + } + ); + } +} + +async function attemptInstalls(filePaths: string[]): Promise { + const promises: Promise[] = []; + + for (const filePath of filePaths) { + promises.push(attemptInstall({ + fileName: path.basename(filePath), + dataP: readFileNotify(filePath), + })); + } + + await Promise.allSettled(promises); +} + +async function installOnDrop(files: File[]) { + logger.info("Install from D&D"); + await attemptInstalls(files.map(({ path }) => path)); +} + +async function installFromInput(input: string) { + 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); + + await attemptInstall({ fileName, dataP: promise }, disposer); + } else if (InputValidators.isPath.validate(input)) { + // install from system path + const fileName = path.basename(input); + + await attemptInstall({ fileName, dataP: readFileNotify(input) }); + } else if (InputValidators.isExtensionNameInstall.validate(input)) { + const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)]; + + await attemptInstallByInfo({ name, version }); + } + } 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?.(); + } +} + +const supportedFormats = ["tar", "tgz"]; + +async function installFromSelectFileDialog() { + const { dialog, BrowserWindow, app } = remote; + const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { + defaultPath: app.getPath("downloads"), + properties: ["openFile", "multiSelections"], + message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `, + buttonLabel: "Use configuration", + filters: [ + { name: "tarball", extensions: supportedFormats } + ] + }); + + if (!canceled) { + await attemptInstalls(filePaths); + } } @observer export class Extensions extends React.Component { - private static supportedFormats = ["tar", "tgz"]; + private static installInputValidators = [ + InputValidators.isUrl, + InputValidators.isPath, + InputValidators.isExtensionNameInstall, + ]; - private static installPathValidator: InputValidator = { - message: "Invalid URL or absolute path", - validate(value: string) { - return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); - } + private static installInputValidator: InputValidator = { + message: "Invalid URL, absolute path, or extension name", + validate: (value: string) => ( + Extensions.installInputValidators.some(({ validate }) => validate(value)) + ), }; - static installStates = observable.map(); - @observable search = ""; @observable installPath = ""; - // True if the preliminary install steps have started, but unpackExtension has not started yet - @observable startingInstall = false; - - /** - * Extensions that were removed from extensions but are still in "uninstalling" state - */ - @computed get removedUninstalling() { - return Array.from(Extensions.installStates.entries()) - .filter(([id, extension]) => - extension.state === "uninstalling" - && !this.extensions.find(extension => extension.id === id) - ) - .map(([id, extension]) => ({ ...extension, id })); - } - - /** - * Extensions that were added to extensions but are still in "installing" state - */ - @computed get addedInstalling() { - return Array.from(Extensions.installStates.entries()) - .filter(([id, extension]) => - extension.state === "installing" - && this.extensions.find(extension => extension.id === id) - ) - .map(([id, extension]) => ({ ...extension, id })); - } - - componentDidMount() { - disposeOnUnmount(this, - reaction(() => this.extensions, () => { - this.removedUninstalling.forEach(({ id, displayName }) => { - Notifications.ok( -

Extension {displayName} successfully uninstalled!

- ); - Extensions.installStates.delete(id); - }); - - this.addedInstalling.forEach(({ id, displayName }) => { - const extension = this.extensions.find(extension => extension.id === id); - - if (!extension) { - throw new Error("Extension not found"); - } - - Notifications.ok( -

Extension {displayName} successfully installed!

- ); - Extensions.installStates.delete(id); - this.installPath = ""; - - // Enable installed extensions by default. - extension.isEnabled = true; - }); - }) - ); - } - - @computed get extensions() { + @computed get searchedForExtensions() { const searchText = this.search.toLowerCase(); return Array.from(ExtensionLoader.getInstance().userExtensions.values()) @@ -127,368 +472,104 @@ export class Extensions extends React.Component { )); } - get extensionsPath() { - return ExtensionDiscovery.getInstance().localFolderPath; - } - - getExtensionPackageTemp(fileName = "") { - return path.join(os.tmpdir(), "lens-extensions", fileName); - } - - getExtensionDestFolder(name: string) { - return path.join(this.extensionsPath, sanitizeExtensionName(name)); - } - - installFromSelectFileDialog = async () => { - const { dialog, BrowserWindow, app } = remote; - const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { - defaultPath: app.getPath("downloads"), - properties: ["openFile", "multiSelections"], - message: `Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `, - buttonLabel: `Use configuration`, - filters: [ - { name: "tarball", extensions: Extensions.supportedFormats } - ] - }); - - if (!canceled && filePaths.length) { - this.requestInstall( - filePaths.map(filePath => ({ - fileName: path.basename(filePath), - filePath, - })) - ); - } - }; - - installFromUrlOrPath = async () => { - const { installPath } = this; - - if (!installPath) return; - - this.startingInstall = true; - const fileName = path.basename(installPath); - - try { - // install via url - // fixme: improve error messages for non-tar-file URLs - if (InputValidators.isUrl.validate(installPath)) { - const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ }); - const data = await filePromise; - - await this.requestInstall({ fileName, data }); - } - // otherwise installing from system path - else if (InputValidators.isPath.validate(installPath)) { - await this.requestInstall({ fileName, filePath: installPath }); - } - } catch (error) { - this.startingInstall = false; - Notifications.error( -

Installation has failed: {String(error)}

- ); - } - }; - - installOnDrop = (files: File[]) => { - logger.info("Install from D&D"); - - return this.requestInstall( - files.map(file => ({ - fileName: path.basename(file.path), - filePath: file.path, - })) - ); - }; - - async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) { - const preloadedRequests = requests.filter(request => request.data); - - await Promise.all( - requests - .filter(request => !request.data && request.filePath) - .map(async request => { - try { - const data = await fse.readFile(request.filePath); - - request.data = data; - preloadedRequests.push(request); - - return request; - } catch(error) { - if (showError) { - Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`); - } - } - }) - ); - - return preloadedRequests as InstallRequestPreloaded[]; - } - - async validatePackage(filePath: string): Promise { - const tarFiles = await listTarEntries(filePath); - - // tarball from npm contains single root folder "package/*" - const firstFile = tarFiles[0]; - - if (!firstFile) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const rootFolder = path.normalize(firstFile).split(path.sep)[0]; - const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); - const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; - - if (!tarFiles.includes(manifestLocation)) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const manifest = await readFileFromTar({ - tarPath: filePath, - filePath: manifestLocation, - parseJson: true, - }); - - if (!manifest.lens && !manifest.renderer) { - throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); - } - - return manifest; - } - - async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { - const validatedRequests: InstallRequestValidated[] = []; - - // copy files to temp - await fse.ensureDir(this.getExtensionPackageTemp()); - - for (const request of requests) { - const tempFile = this.getExtensionPackageTemp(request.fileName); - - await fse.writeFile(tempFile, request.data); - } - - // validate packages - await Promise.all( - requests.map(async req => { - const tempFile = this.getExtensionPackageTemp(req.fileName); + componentDidMount() { + // TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality + let prevSize = ExtensionLoader.getInstance().userExtensions.size; + disposeOnUnmount(this, [ + reaction(() => ExtensionLoader.getInstance().userExtensions.size, curSize => { try { - const manifest = await this.validatePackage(tempFile); - - validatedRequests.push({ - ...req, - manifest, - tempFile, - }); - } catch (error) { - fse.unlink(tempFile).catch(() => null); // remove invalid temp package - - if (showErrors) { - Notifications.error( -
-

Installing {req.fileName} has failed, skipping.

-

Reason: {String(error)}

-
- ); + if (curSize > prevSize) { + when(() => !ExtensionInstallationStateStore.anyInstalling) + .then(() => this.installPath = ""); } + } finally { + prevSize = curSize; } }) + ]); + } + + renderNoExtensionsHelpText() { + if (this.search) { + return

No search results found

; + } + + return ( +

+ There are no installed extensions. + See list of available extensions. +

); - - return validatedRequests; } - async requestInstall(init: InstallRequest | InstallRequest[]) { - const requests = Array.isArray(init) ? init : [init]; - const preloadedRequests = await this.preloadExtensions(requests); - const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); - - // If there are no requests for installing, reset startingInstall state - if (validatedRequests.length === 0) { - this.startingInstall = false; - } - - for (const install of validatedRequests) { - const { name, version, description } = install.manifest; - const extensionFolder = this.getExtensionDestFolder(name); - const folderExists = await fse.pathExists(extensionFolder); - - if (!folderExists) { - // auto-install extension if not yet exists - this.unpackExtension(install); - } else { - // If we show the confirmation dialog, we stop the install spinner until user clicks ok - // and the install continues - this.startingInstall = false; - - // otherwise confirmation required (re-install / update) - const removeNotification = Notifications.info( -
-
-

Install extension {name}@{version}?

-

Description: {description}

-
shell.openPath(extensionFolder)}> - Warning: {extensionFolder} will be removed before installation. -
-
-
- ); - } - } + renderNoExtensions() { + return ( +
+ +
+ {this.renderNoExtensionsHelpText()} +
+
+ ); } - async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { - const displayName = extensionDisplayName(name, version); - const extensionId = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, name, "package.json"); + @autobind() + renderExtension(extension: InstalledExtension) { + const { id, isEnabled, manifest } = extension; + const { name, description, version } = manifest; + const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - Extensions.installStates.set(extensionId, { - state: "installing", - displayName - }); - this.startingInstall = false; - - const extensionFolder = this.getExtensionDestFolder(name); - const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); - - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - try { - // extract to temp folder first - await fse.remove(unpackingTempFolder).catch(Function); - await fse.ensureDir(unpackingTempFolder); - await extractTar(tempFile, { cwd: unpackingTempFolder }); - - // move contents to extensions folder - const unpackedFiles = await fse.readdir(unpackingTempFolder); - let unpackedRootFolder = unpackingTempFolder; - - if (unpackedFiles.length === 1) { - // check if %extension.tgz was packed with single top folder, - // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball - unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); - } - - await fse.ensureDir(extensionFolder); - await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); - } catch (error) { - Notifications.error( -

Installing extension {displayName} has failed: {error}

- ); - - // Remove install state on install failure - if (Extensions.installStates.get(extensionId)?.state === "installing") { - Extensions.installStates.delete(extensionId); - } - } finally { - // clean up - fse.remove(unpackingTempFolder).catch(Function); - fse.unlink(tempFile).catch(Function); - } - } - - confirmUninstallExtension = (extension: InstalledExtension) => { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - ConfirmDialog.open({ - message:

Are you sure you want to uninstall extension {displayName}?

, - labelOk: "Yes", - labelCancel: "No", - ok: () => this.uninstallExtension(extension) - }); - }; - - async uninstallExtension(extension: InstalledExtension) { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - try { - Extensions.installStates.set(extension.id, { - state: "uninstalling", - displayName - }); - - await ExtensionDiscovery.getInstance().uninstallExtension(extension); - } catch (error) { - Notifications.error( -

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

- ); - - // Remove uninstall state on uninstall failure - if (Extensions.installStates.get(extension.id)?.state === "uninstalling") { - Extensions.installStates.delete(extension.id); - } - } + return ( +
+
+
{name}
+
{version}
+

{description}

+
+
+ { + isEnabled + ? + : + } + +
+
+ ); } renderExtensions() { - const { extensions, search } = this; - - if (!extensions.length) { - return ( -
- -
- { - search - ?

No search results found

- :

There are no installed extensions. See list of available extensions.

- } -
-
- ); + if (!ExtensionDiscovery.getInstance().isLoaded) { + return
; } - return extensions.map(extension => { - const { id, isEnabled, manifest } = extension; - const { name, description, version } = manifest; - const isUninstalling = Extensions.installStates.get(id)?.state === "uninstalling"; + const { searchedForExtensions } = this; - return ( -
-
-
{name}
-
{version}
-

{description}

-
-
- {!isEnabled && ( - - )} - {isEnabled && ( - - )} - -
-
- ); - }); - } + if (!searchedForExtensions.length) { + return this.renderNoExtensions(); + } - /** - * True if at least one extension is in installing state - */ - @computed get isInstalling() { - return [...Extensions.installStates.values()].some(extension => extension.state === "installing"); + return ( + <> + {...searchedForExtensions.map(this.renderExtension)} + + ); } render() { const { installPath } = this; return ( - +

Lens Extensions

@@ -502,19 +583,19 @@ export class Extensions extends React.Component { this.installPath = value} - onSubmit={this.installFromUrlOrPath} + onSubmit={() => installFromInput(this.installPath)} iconLeft="link" iconRight={ } @@ -523,9 +604,9 @@ export class Extensions extends React.Component {
diff --git a/src/renderer/components/button/button.tsx b/src/renderer/components/button/button.tsx index 8bcb37bad4..da1d88d13f 100644 --- a/src/renderer/components/button/button.tsx +++ b/src/renderer/components/button/button.tsx @@ -1,5 +1,5 @@ import "./button.scss"; -import React, { ButtonHTMLAttributes, ReactNode } from "react"; +import React, { ButtonHTMLAttributes } from "react"; import { cssNames } from "../../utils"; import { TooltipDecoratorProps, withTooltip } from "../tooltip"; @@ -26,29 +26,22 @@ export class Button extends React.PureComponent { render() { const { - className, waiting, label, primary, accent, plain, hidden, active, big, - round, outlined, tooltip, light, children, ...props + waiting, label, primary, accent, plain, hidden, active, big, + round, outlined, tooltip, light, children, ...btnProps } = this.props; - const btnProps: Partial = props; if (hidden) return null; - btnProps.className = cssNames("Button", className, { + btnProps.className = cssNames("Button", btnProps.className, { waiting, primary, accent, plain, active, big, round, outlined, light, }); - const btnContent: ReactNode = ( - <> - {label} - {children} - - ); - // render as link if (this.props.href) { return ( this.link = e}> - {btnContent} + {label} + {children} ); } @@ -56,7 +49,8 @@ export class Button extends React.PureComponent { // render as button return ( ); } diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 721ee36e45..0c29b40c02 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -11,14 +11,18 @@ import { Icon } from "../icon"; export interface ConfirmDialogProps extends Partial { } -export interface ConfirmDialogParams { - ok?: () => void; +export interface ConfirmDialogParams extends ConfirmDialogBooleanParams { + ok?: () => any | Promise; + cancel?: () => any | Promise; +} + +export interface ConfirmDialogBooleanParams { labelOk?: ReactNode; labelCancel?: ReactNode; - message?: ReactNode; + message: ReactNode; icon?: ReactNode; - okButtonProps?: Partial - cancelButtonProps?: Partial + okButtonProps?: Partial; + cancelButtonProps?: Partial; } @observer @@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component { ConfirmDialog.params = params; } - static close() { - ConfirmDialog.isOpen = false; + static confirm(params: ConfirmDialogBooleanParams): Promise { + return new Promise(resolve => { + ConfirmDialog.open({ + ok: () => resolve(true), + cancel: () => resolve(false), + ...params, + }); + }); } - public defaultParams: ConfirmDialogParams = { + static defaultParams: Partial = { ok: noop, + cancel: noop, labelOk: "Ok", labelCancel: "Cancel", icon: , }; get params(): ConfirmDialogParams { - return Object.assign({}, this.defaultParams, ConfirmDialog.params); + return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params); } ok = async () => { @@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component { await Promise.resolve(this.params.ok()).catch(noop); } finally { this.isSaving = false; + ConfirmDialog.isOpen = false; } - this.close(); }; onClose = () => { this.isSaving = false; }; - close = () => { - ConfirmDialog.close(); + close = async () => { + try { + await Promise.resolve(this.params.cancel()).catch(noop); + } finally { + this.isSaving = false; + ConfirmDialog.isOpen = false; + } }; render() { diff --git a/src/renderer/components/dock/install-chart.store.ts b/src/renderer/components/dock/install-chart.store.ts index 7a11deb65a..3e06a2de43 100644 --- a/src/renderer/components/dock/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart.store.ts @@ -1,7 +1,7 @@ import { action, autorun } from "mobx"; import { dockStore, IDockTab, TabId, TabKind } from "./dock.store"; import { DockTabStore } from "./dock-tab.store"; -import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { getChartDetails, getChartValues, HelmChart } from "../../api/endpoints/helm-charts.api"; import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api"; import { Notifications } from "../notifications"; @@ -54,7 +54,7 @@ export class InstallChartStore extends DockTabStore { const { repo, name, version } = this.getData(tabId); this.versions.clearData(tabId); // reset - const charts = await helmChartsApi.get(repo, name, version); + const charts = await getChartDetails(repo, name, { version }); const versions = charts.versions.map(chartVersion => chartVersion.version); this.versions.setData(tabId, versions); @@ -64,7 +64,7 @@ export class InstallChartStore extends DockTabStore { async loadValues(tabId: TabId, attempt = 0): Promise { const data = this.getData(tabId); const { repo, name, version } = data; - const values = await helmChartsApi.getValues(repo, name, version); + const values = await getChartValues(repo, name, version); if (values) { this.setData(tabId, { ...data, values }); diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx index 8400aef584..3fc8d9eb14 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/log-controls.tsx @@ -11,7 +11,7 @@ import { Icon } from "../icon"; import { LogTabData } from "./log-tab.store"; interface Props { - tabData: LogTabData + tabData?: LogTabData logs: string[] save: (data: Partial) => void reload: () => void @@ -19,6 +19,11 @@ interface Props { export const LogControls = observer((props: Props) => { const { tabData, save, reload, logs } = props; + + if (!tabData) { + return null; + } + const { showTimestamps, previous } = tabData; const since = logs.length ? logStore.getTimestamps(logs[0]) : null; const pod = new Pod(tabData.selectedPod); diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx index 0aa31f95fb..07f65c6e63 100644 --- a/src/renderer/components/dock/logs.tsx +++ b/src/renderer/components/dock/logs.tsx @@ -26,23 +26,14 @@ export class Logs extends React.Component { componentDidMount() { disposeOnUnmount(this, - reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }) + reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }), ); } - get tabData() { - return logTabStore.getData(this.tabId); - } - get tabId() { return this.props.tab.id; } - @autobind() - save(data: Partial) { - logTabStore.setData(this.tabId, { ...this.tabData, ...data }); - } - load = async () => { this.isLoading = true; await logStore.load(this.tabId); @@ -82,15 +73,19 @@ export class Logs extends React.Component { }, 100); } - renderResourceSelector() { + renderResourceSelector(data?: LogTabData) { + if (!data) { + return null; + } + const logs = logStore.logs; - const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps; + const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps; const controls = (
logTabStore.setData(this.tabId, { ...data, ...newData })} reload={this.reload} /> { render() { const logs = logStore.logs; + const data = logTabStore.getData(this.tabId); + + if (!data) { + this.reload(); + } return (
- {this.renderResourceSelector()} + {this.renderResourceSelector(data)} { /> logTabStore.setData(this.tabId, { ...data, ...newData })} reload={this.reload} />
diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index ce0594c0e6..ad3b77c8e8 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -315,6 +315,7 @@ export class Input extends React.Component { rows: multiLine ? (rows || 1) : null, ref: this.bindRef, spellCheck: "false", + disabled, }); const showErrors = errors.length > 0 && !valid && dirty; const errorsInfo = ( diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index ae5fd6d1e1..c96d63a4c5 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -47,6 +47,14 @@ export const isUrl: InputValidator = { }, }; +export const isExtensionNameInstallRegex = /^(?(@[-\w]+\/)?[-\w]+)(@(?\d\.\d\.\d(-\w+)?))?$/gi; + +export const isExtensionNameInstall: InputValidator = { + condition: ({ type }) => type === "text", + message: () => "Not an extension name with optional version", + validate: value => value.match(isExtensionNameInstallRegex) !== null, +}; + export const isPath: InputValidator = { condition: ({ type }) => type === "text", message: () => `This field must be a valid path`, diff --git a/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx b/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx index 386255d1eb..a9813d02db 100644 --- a/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx +++ b/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx @@ -2,105 +2,106 @@ import "./kube-object-status-icon.scss"; import React from "react"; import { Icon } from "../icon"; -import { KubeObject } from "../../api/kube-object"; import { cssNames, formatDuration } from "../../utils"; -import { KubeObjectStatusRegistration, kubeObjectStatusRegistry } from "../../../extensions/registries/kube-object-status-registry"; -import { KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api"; -import { computed } from "mobx"; +import { KubeObject, KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api"; +import { kubeObjectStatusRegistry } from "../../../extensions/registries"; + +function statusClassName(level: number): string { + switch (level) { + case KubeObjectStatusLevel.INFO: + return "info"; + case KubeObjectStatusLevel.WARNING: + return "warning"; + case KubeObjectStatusLevel.CRITICAL: + return "error"; + } +} + +function statusTitle(level: KubeObjectStatusLevel): string { + switch (level) { + case KubeObjectStatusLevel.INFO: + return "Info"; + case KubeObjectStatusLevel.WARNING: + return "Warning"; + case KubeObjectStatusLevel.CRITICAL: + return "Critical"; + } +} + +function getAge(timestamp: string) { + return timestamp + ? formatDuration(Date.now() - new Date(timestamp).getTime(), true) + : ""; +} + +interface SplitStatusesByLevel { + maxLevel: string, + criticals: KubeObjectStatus[]; + warnings: KubeObjectStatus[]; + infos: KubeObjectStatus[]; +} + +/** + * This fuction returns the class level for corresponding to the highest status level + * and the statuses split by their levels. + * @param src a list of status items + */ +function splitByLevel(src: KubeObjectStatus[]): SplitStatusesByLevel { + const parts = new Map(Object.values(KubeObjectStatusLevel).map(v => [v, []])); + + src.forEach(status => parts.get(status.level).push(status)); + + const criticals = parts.get(KubeObjectStatusLevel.CRITICAL); + const warnings = parts.get(KubeObjectStatusLevel.WARNING); + const infos = parts.get(KubeObjectStatusLevel.INFO); + const maxLevel = statusClassName(criticals[0]?.level ?? warnings[0]?.level ?? infos[0].level); + + return { maxLevel, criticals, warnings, infos }; +} interface Props { object: KubeObject; } export class KubeObjectStatusIcon extends React.Component { - @computed get objectStatuses() { - const { object } = this.props; - const registrations = kubeObjectStatusRegistry.getItemsForKind(object.kind, object.apiVersion); - - return registrations.map((item: KubeObjectStatusRegistration) => { return item.resolve(object); }).filter((item: KubeObjectStatus) => !!item); - } - - statusClassName(level: number): string { - switch (level) { - case KubeObjectStatusLevel.INFO: - return "info"; - case KubeObjectStatusLevel.WARNING: - return "warning"; - case KubeObjectStatusLevel.CRITICAL: - return "error"; - default: - return ""; - } - } - - statusTitle(level: number): string { - switch (level) { - case KubeObjectStatusLevel.INFO: - return "Info"; - case KubeObjectStatusLevel.WARNING: - return "Warning"; - case KubeObjectStatusLevel.CRITICAL: - return "Critical"; - default: - return ""; - } - } - - getAge(timestamp: string) { - if (!timestamp) return ""; - const diff = Date.now() - new Date(timestamp).getTime(); - - return formatDuration(diff, true); - } - renderStatuses(statuses: KubeObjectStatus[], level: number) { const filteredStatuses = statuses.filter((item) => item.level == level); return filteredStatuses.length > 0 && ( -
+
- {this.statusTitle(level)} + {statusTitle(level)} - { filteredStatuses.map((status, index) =>{ - return ( + { + filteredStatuses.map((status, index) => (
- - {status.text} · { this.getAge(status.timestamp) } + - {status.text} · {getAge(status.timestamp)}
- ); - })} + )) + }
); } render() { - const { objectStatuses} = this; + const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object); - if (!objectStatuses.length) return null; + if (statuses.length === 0) { + return null; + } - const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => { - if (a.level < b.level ) { - return 1; - } - - if (a.level > b.level ) { - return -1; - } - - return 0; - }); - - const level = this.statusClassName(sortedStatuses[0].level); + const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses); return ( - {this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.CRITICAL)} - {this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.WARNING)} - {this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.INFO)} + {this.renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)} + {this.renderStatuses(warnings, KubeObjectStatusLevel.WARNING)} + {this.renderStatuses(infos, KubeObjectStatusLevel.INFO)}
) }} diff --git a/src/renderer/components/kube-object/kube-object-meta.tsx b/src/renderer/components/kube-object/kube-object-meta.tsx index 7db45990c5..1b37c5c457 100644 --- a/src/renderer/components/kube-object/kube-object-meta.tsx +++ b/src/renderer/components/kube-object/kube-object-meta.tsx @@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component { } render() { - const object = this.props.object; + const { object } = this.props; const { - getName, getNs, getLabels, getResourceVersion, selfLink, - getAnnotations, getFinalizers, getId, getAge, - metadata: { creationTimestamp }, + getNs, getLabels, getResourceVersion, selfLink, getAnnotations, + getFinalizers, getId, getAge, getName, metadata: { creationTimestamp }, } = object; - const ownerRefs = object.getOwnerRefs(); return ( @@ -39,7 +37,8 @@ export class KubeObjectMeta extends React.Component { {getAge(true, false)} ago ({})
diff --git a/src/renderer/components/spinner/spinner.scss b/src/renderer/components/spinner/spinner.scss index b8843b542d..75c3839152 100644 --- a/src/renderer/components/spinner/spinner.scss +++ b/src/renderer/components/spinner/spinner.scss @@ -34,12 +34,6 @@ margin-top: calc(var(--spinner-size) / -2); } - &.centerHorizontal { - position: absolute; - left: 50%; - margin-left: calc(var(--spinner-size) / -2); - } - @keyframes rotate { 0% { transform: rotate(0deg); diff --git a/src/renderer/components/spinner/spinner.tsx b/src/renderer/components/spinner/spinner.tsx index 9708221252..32c764b80a 100644 --- a/src/renderer/components/spinner/spinner.tsx +++ b/src/renderer/components/spinner/spinner.tsx @@ -6,7 +6,6 @@ import { cssNames } from "../../utils"; export interface SpinnerProps extends React.HTMLProps { singleColor?: boolean; center?: boolean; - centerHorizontal?: boolean; } export class Spinner extends React.Component { @@ -16,8 +15,8 @@ export class Spinner extends React.Component { }; render() { - const { center, singleColor, centerHorizontal, className, ...props } = this.props; - const classNames = cssNames("Spinner", className, { singleColor, center, centerHorizontal }); + const { center, singleColor, className, ...props } = this.props; + const classNames = cssNames("Spinner", className, { singleColor, center }); return
; } diff --git a/src/renderer/protocol-handler/app-handlers.ts b/src/renderer/protocol-handler/app-handlers.ts index abc1607303..eeae6ccffc 100644 --- a/src/renderer/protocol-handler/app-handlers.ts +++ b/src/renderer/protocol-handler/app-handlers.ts @@ -1,6 +1,6 @@ import { addClusterURL } from "../components/+add-cluster"; -import { extensionsURL } from "../components/+extensions"; import { catalogURL } from "../components/+catalog"; +import { attemptInstallByInfo, extensionsURL } from "../components/+extensions"; import { preferencesURL } from "../components/+preferences"; import { clusterViewURL } from "../components/cluster-manager/cluster-view.route"; import { LensProtocolRouterRenderer } from "./router"; @@ -8,6 +8,7 @@ import { navigate } from "../navigation/helpers"; import { entitySettingsURL } from "../components/+entity-settings"; import { catalogEntityRegistry } from "../api/catalog-entity-registry"; import { ClusterStore } from "../../common/cluster-store"; +import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler"; export function bindProtocolAddRouteHandlers() { LensProtocolRouterRenderer @@ -33,9 +34,6 @@ export function bindProtocolAddRouteHandlers() { console.log("[APP-HANDLER]: catalog entity with given ID does not exist", { entityId }); } }) - .addInternalHandler("/extensions", () => { - navigate(extensionsURL()); - }) // Handlers below are deprecated and only kept for backward compact purposes .addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => { const cluster = ClusterStore.getInstance().getById(clusterId); @@ -54,5 +52,18 @@ export function bindProtocolAddRouteHandlers() { } else { console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId }); } + }) + .addInternalHandler("/extensions", () => { + navigate(extensionsURL()); + }) + .addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version } }) => { + const name = [ + pathname[EXTENSION_PUBLISHER_MATCH], + pathname[EXTENSION_NAME_MATCH], + ].filter(Boolean) + .join("/"); + + navigate(extensionsURL()); + attemptInstallByInfo({ name, version, requireConfirmation: true }); }); } diff --git a/src/renderer/utils/cancelableFetch.ts b/src/renderer/utils/cancelableFetch.ts deleted file mode 100644 index a4a197fe0d..0000000000 --- a/src/renderer/utils/cancelableFetch.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Allow to cancel request for window.fetch() - -export interface CancelablePromise extends Promise { - then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): CancelablePromise; - catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): CancelablePromise; - finally(onfinally?: (() => void) | undefined | null): CancelablePromise; - cancel(): void; -} - -interface WrappingFunction { - (result: Promise): CancelablePromise; - (result: T): T; -} - -export function cancelableFetch(reqInfo: RequestInfo, reqInit: RequestInit = {}) { - const abortController = new AbortController(); - const signal = abortController.signal; - const cancel = abortController.abort.bind(abortController); - const wrapResult: WrappingFunction = function (result: any) { - if (result instanceof Promise) { - const promise: CancelablePromise = result as any; - - promise.then = function (onfulfilled, onrejected) { - const data = Object.getPrototypeOf(this).then.call(this, onfulfilled, onrejected); - - return wrapResult(data); - }; - promise.cancel = cancel; - } - - return result; - }; - const req = fetch(reqInfo, { ...reqInit, signal }); - - return wrapResult(req); -} From b1274cbb33636204a54db71b9c6127d08b7e3ee8 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 27 Apr 2021 11:24:39 +0300 Subject: [PATCH 5/8] Extensible welcome page (#2637) * welcome/landing page Signed-off-by: Jari Kolehmainen * fix integration tests Signed-off-by: Jari Kolehmainen --- integration/__tests__/app.tests.ts | 4 - integration/__tests__/cluster-pages.tests.ts | 2 - .../__tests__/command-palette.tests.ts | 1 - integration/helpers/utils.ts | 18 +- src/extensions/extension-loader.ts | 1 + src/extensions/lens-renderer-extension.ts | 6 +- src/extensions/registries/index.ts | 1 + .../registries/welcome-menu-registry.ts | 11 + src/main/menu.ts | 6 +- src/renderer/components/+catalog/catalog.tsx | 10 - src/renderer/components/+welcome/index.ts | 2 + .../components/+welcome/welcome.route.ts | 8 + src/renderer/components/+welcome/welcome.scss | 49 ++ src/renderer/components/+welcome/welcome.tsx | 58 ++ src/renderer/components/+whats-new/index.tsx | 2 - .../components/+whats-new/whats-new.route.ts | 8 - .../components/+whats-new/whats-new.scss | 50 -- .../components/+whats-new/whats-new.tsx | 43 -- .../components/cluster-manager/bottom-bar.tsx | 2 +- .../cluster-manager/cluster-manager.tsx | 10 +- src/renderer/lens-app.tsx | 4 - static/RELEASE_NOTES.md | 671 ------------------ 22 files changed, 147 insertions(+), 820 deletions(-) create mode 100644 src/extensions/registries/welcome-menu-registry.ts create mode 100644 src/renderer/components/+welcome/index.ts create mode 100644 src/renderer/components/+welcome/welcome.route.ts create mode 100644 src/renderer/components/+welcome/welcome.scss create mode 100644 src/renderer/components/+welcome/welcome.tsx delete mode 100644 src/renderer/components/+whats-new/index.tsx delete mode 100644 src/renderer/components/+whats-new/whats-new.route.ts delete mode 100644 src/renderer/components/+whats-new/whats-new.scss delete mode 100644 src/renderer/components/+whats-new/whats-new.tsx delete mode 100644 static/RELEASE_NOTES.md diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index fa1f4ef286..5f0fe9df04 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -30,10 +30,6 @@ describe("Lens integration tests", () => { } }); - it('shows "whats new"', async () => { - await utils.clickWhatsNew(app); - }); - it('shows "add cluster"', async () => { await app.electron.ipcRenderer.send("test-menu-item-click", "File", "Add Cluster"); await app.client.waitUntilTextExists("h2", "Add Clusters from Kubeconfig"); diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 00c5a7cdc8..7e88c77608 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -24,8 +24,6 @@ describe("Lens cluster pages", () => { utils.describeIf(ready)("test common pages", () => { let clusterAdded = false; const addCluster = async () => { - await utils.clickWhatsNew(app); - await utils.clickWelcomeNotification(app); await app.client.waitUntilTextExists("div", "Catalog"); await addMinikubeCluster(app); await waitForMinikubeDashboard(app); diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts index 08da064bd2..6f924f5524 100644 --- a/integration/__tests__/command-palette.tests.ts +++ b/integration/__tests__/command-palette.tests.ts @@ -18,7 +18,6 @@ describe("Lens command palette", () => { }); it("opens command dialog from menu", async () => { - await utils.clickWhatsNew(app); await app.electron.ipcRenderer.send("test-menu-item-click", "View", "Command Palette..."); await app.client.waitUntilTextExists(".Select__option", "Preferences: Open"); await app.client.keys("Escape"); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index e0862e1d36..bb41e65bdd 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -73,24 +73,14 @@ export async function appStart() { while (await app.client.getWindowCount() > 1); await app.client.windowByIndex(0); await app.client.waitUntilWindowLoaded(); + await showCatalog(app); return app; } -export async function clickWhatsNew(app: Application) { - await app.client.waitUntilTextExists("h1", "What's new?"); - await app.client.click("button.primary"); - await app.client.waitUntilTextExists("div", "Catalog"); -} - -export async function clickWelcomeNotification(app: Application) { - const itemsText = await app.client.$("div.info-panel").getText(); - - if (itemsText === "0 items") { - // welcome notification should be present, dismiss it - await app.client.waitUntilTextExists("div.message", "Welcome!"); - await app.client.click(".notification i.Icon.close"); - } +export async function showCatalog(app: Application) { + await app.client.waitUntilTextExists("[data-test-id=catalog-link]", "Catalog"); + await app.client.click("[data-test-id=catalog-link]"); } type AsyncPidGetter = () => Promise; diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 4846dd7e82..e7ec6d9529 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -214,6 +214,7 @@ export class ExtensionLoader extends Singleton { registries.entitySettingRegistry.add(extension.entitySettings), registries.statusBarRegistry.add(extension.statusBarItems), registries.commandRegistry.add(extension.commands), + registries.welcomeMenuRegistry.add(extension.welcomeMenus), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index fabb876408..ae45128ed2 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -1,4 +1,7 @@ -import type { AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"; +import type { + AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, + KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, +} from "./registries"; import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; @@ -17,6 +20,7 @@ export class LensRendererExtension extends LensExtension { kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; commands: CommandRegistration[] = []; + welcomeMenus: WelcomeMenuRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 419f717477..e98bba9da4 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -10,3 +10,4 @@ export * from "./kube-object-menu-registry"; export * from "./kube-object-status-registry"; export * from "./command-registry"; export * from "./entity-setting-registry"; +export * from "./welcome-menu-registry"; diff --git a/src/extensions/registries/welcome-menu-registry.ts b/src/extensions/registries/welcome-menu-registry.ts new file mode 100644 index 0000000000..14541538a8 --- /dev/null +++ b/src/extensions/registries/welcome-menu-registry.ts @@ -0,0 +1,11 @@ +import { BaseRegistry } from "./base-registry"; + +export interface WelcomeMenuRegistration { + title: string; + icon: string; + click: () => void | Promise; +} + +export class WelcomeMenuRegistry extends BaseRegistry {} + +export const welcomeMenuRegistry = new WelcomeMenuRegistry(); diff --git a/src/main/menu.ts b/src/main/menu.ts index 842f95efae..a4e715a60b 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -4,7 +4,7 @@ import { WindowManager } from "./window-manager"; import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl, productName } from "../common/vars"; import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; -import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; +import { welcomeURL } from "../renderer/components/+welcome/welcome.route"; import { extensionsURL } from "../renderer/components/+extensions/extensions.route"; import { catalogURL } from "../renderer/components/+catalog/catalog.route"; import { menuRegistry } from "../extensions/registries/menu-registry"; @@ -201,9 +201,9 @@ export function buildMenu(windowManager: WindowManager) { role: "help", submenu: [ { - label: "What's new?", + label: "Welcome", click() { - navigate(whatsNewURL()); + navigate(welcomeURL()); }, }, { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 54e6cc21f2..b04a83e9c6 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -13,7 +13,6 @@ import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntit import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { autobind } from "../../utils"; -import { Notifications } from "../notifications"; import { ConfirmDialog } from "../confirm-dialog"; import { Tab, Tabs } from "../tabs"; import { catalogCategoryRegistry } from "../../../common/catalog"; @@ -45,15 +44,6 @@ export class Catalog extends React.Component { } }, { fireImmediately: true }) ]); - - setTimeout(() => { - if (this.catalogEntityStore.items.length === 0) { - Notifications.info(<>Welcome!

Get started by associating one or more clusters to Lens

, { - timeout: 30_000, - id: "catalog-welcome" - }); - } - }, 2_000); } addToHotbar(item: CatalogEntityItem) { diff --git a/src/renderer/components/+welcome/index.ts b/src/renderer/components/+welcome/index.ts new file mode 100644 index 0000000000..15cf0b0857 --- /dev/null +++ b/src/renderer/components/+welcome/index.ts @@ -0,0 +1,2 @@ +export * from "./welcome"; +export * from "./welcome.route"; diff --git a/src/renderer/components/+welcome/welcome.route.ts b/src/renderer/components/+welcome/welcome.route.ts new file mode 100644 index 0000000000..b72190b2f9 --- /dev/null +++ b/src/renderer/components/+welcome/welcome.route.ts @@ -0,0 +1,8 @@ +import type { RouteProps } from "react-router"; +import { buildURL } from "../../../common/utils/buildUrl"; + +export const welcomeRoute: RouteProps = { + path: "/welcome" +}; + +export const welcomeURL = buildURL(welcomeRoute.path); diff --git a/src/renderer/components/+welcome/welcome.scss b/src/renderer/components/+welcome/welcome.scss new file mode 100644 index 0000000000..356599873b --- /dev/null +++ b/src/renderer/components/+welcome/welcome.scss @@ -0,0 +1,49 @@ +.Welcome { + text-align: center; + width: 100%; + z-index: 1; + + .box { + width: 320px; + } + + h2 { + color: var(--textColorAccent); + font-weight: 600; + margin-top: 15px; + margin-bottom: 20px; + } + + p { + line-height: 1.5; + } + + ul { + width: 200px; + margin-top: 20px; + + li { + text-align: left; + line-height: 1.5; + background-color: var(--layoutBackground); + padding: 7px; + border-radius: 4px; + margin-bottom: 7px; + cursor: pointer; + + a { + margin-left: 10px; + border-bottom: none; + } + } + li:hover { + color: var(--textColorAccent); + } + } + + .Icon.logo { + width: 200px; + height: 200px; + color: var(--primary); + } +} diff --git a/src/renderer/components/+welcome/welcome.tsx b/src/renderer/components/+welcome/welcome.tsx new file mode 100644 index 0000000000..aff6ebf002 --- /dev/null +++ b/src/renderer/components/+welcome/welcome.tsx @@ -0,0 +1,58 @@ +import "./welcome.scss"; +import React from "react"; +import { observer } from "mobx-react"; +import { Icon } from "../icon"; +import { productName, slackUrl } from "../../../common/vars"; +import { welcomeMenuRegistry } from "../../../extensions/registries"; +import { navigate } from "../../navigation"; +import { catalogURL } from "../+catalog"; +import { preferencesURL } from "../+preferences"; + +@observer +export class Welcome extends React.Component { + + componentDidMount() { + if (welcomeMenuRegistry.getItems().find((item) => item.title === "Browse Your Catalog")) { + return; + } + + welcomeMenuRegistry.add({ + title: "Browse Your Catalog", + icon: "view_list", + click: () => navigate(catalogURL()) + }); + + if (welcomeMenuRegistry.getItems().length === 1) { + welcomeMenuRegistry.add({ + title: "Configure Preferences", + icon: "settings", + click: () => navigate(preferencesURL()) + }); + } + } + + render() { + return ( +
+
+ + +

Welcome to {productName} 5 Beta!

+ +

+ Here are some steps to help you get started with {productName} 5 Beta. + If you have any questions or feedback, please join our Lens Community slack channel. +

+ +
    + { welcomeMenuRegistry.getItems().map((item, index) => ( +
  • item.click()}> + {item.title} +
  • + ))} +
+
+
+ ); + } +} diff --git a/src/renderer/components/+whats-new/index.tsx b/src/renderer/components/+whats-new/index.tsx deleted file mode 100644 index ebfdf6ff1b..0000000000 --- a/src/renderer/components/+whats-new/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./whats-new.route"; -export * from "./whats-new"; diff --git a/src/renderer/components/+whats-new/whats-new.route.ts b/src/renderer/components/+whats-new/whats-new.route.ts deleted file mode 100644 index e73fd20fca..0000000000 --- a/src/renderer/components/+whats-new/whats-new.route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteProps } from "react-router"; -import { buildURL } from "../../../common/utils/buildUrl"; - -export const whatsNewRoute: RouteProps = { - path: "/what-s-new" -}; - -export const whatsNewURL = buildURL(whatsNewRoute.path); diff --git a/src/renderer/components/+whats-new/whats-new.scss b/src/renderer/components/+whats-new/whats-new.scss deleted file mode 100644 index d7b13ac3e7..0000000000 --- a/src/renderer/components/+whats-new/whats-new.scss +++ /dev/null @@ -1,50 +0,0 @@ -.WhatsNew { - $spacing: $padding * 2; - - &::after { - content: ""; - background: url(../../components/icon/crane.svg) no-repeat; - background-position: 0 35%; - background-size: 85%; - background-clip: content-box; - opacity: .75; - top: 0; - left: 0; - bottom: 0; - right: 0; - position: absolute; - z-index: -1; - - .theme-light & { - opacity: 0.2; - } - } - - .logo { - width: 200px; - margin-bottom: $spacing; - } - - > .content { - overflow: auto; - margin-top: $spacing; - padding: $spacing * 2; - - a { - color: $colorInfo; - text-decoration: underline; - } - - ul { - list-style: disc inside; - line-height: 120%; - padding-left: $spacing * 2; - } - } - - > .bottom { - text-align: center; - padding: $spacing; - background: $contentColor; - } -} \ No newline at end of file diff --git a/src/renderer/components/+whats-new/whats-new.tsx b/src/renderer/components/+whats-new/whats-new.tsx deleted file mode 100644 index 95542195bb..0000000000 --- a/src/renderer/components/+whats-new/whats-new.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import "./whats-new.scss"; -import fs from "fs"; -import path from "path"; -import React from "react"; -import { observer } from "mobx-react"; -import { UserStore } from "../../../common/user-store"; -import { navigate } from "../../navigation"; -import { Button } from "../button"; -import marked from "marked"; - -@observer -export class WhatsNew extends React.Component { - releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString(); - - ok = () => { - navigate("/"); - UserStore.getInstance().saveLastSeenAppVersion(); - }; - - render() { - const logo = require("../../components/icon/lens-logo.svg"); - const releaseNotes = marked(this.releaseNotes); - - return ( -
-
- Lens -
-
-
-
-
- ); - } -} diff --git a/src/renderer/components/cluster-manager/bottom-bar.tsx b/src/renderer/components/cluster-manager/bottom-bar.tsx index eb82fdb6b2..aadb50a1aa 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.tsx @@ -48,7 +48,7 @@ export class BottomBar extends React.Component {
{this.renderRegisteredItems()}
diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 489ffae284..2aa923c402 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -5,7 +5,7 @@ import { Redirect, Route, Switch } from "react-router"; import { comparer, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { BottomBar } from "./bottom-bar"; -import { Catalog, catalogRoute, catalogURL } from "../+catalog"; +import { Catalog, catalogRoute } from "../+catalog"; import { Preferences, preferencesRoute } from "../+preferences"; import { AddCluster, addClusterRoute } from "../+add-cluster"; import { ClusterView } from "./cluster-view"; @@ -17,6 +17,7 @@ import { Extensions, extensionsRoute } from "../+extensions"; import { getMatchedClusterId } from "../../navigation"; import { HotbarMenu } from "../hotbar/hotbar-menu"; import { EntitySettings, entitySettingsRoute } from "../+entity-settings"; +import { Welcome, welcomeRoute, welcomeURL } from "../+welcome"; @observer export class ClusterManager extends React.Component { @@ -43,16 +44,13 @@ export class ClusterManager extends React.Component { lensViews.clear(); } - get startUrl() { - return catalogURL(); - } - render() { return (
+ @@ -65,7 +63,7 @@ export class ClusterManager extends React.Component { )) } - +
diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index adbc32c9e8..add3ef32e9 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -2,11 +2,9 @@ import "../common/system-ca"; import React from "react"; import { Route, Router, Switch } from "react-router"; import { observer } from "mobx-react"; -import { UserStore } from "../common/user-store"; import { history } from "./navigation"; import { ClusterManager } from "./components/cluster-manager"; import { ErrorBoundary } from "./components/error-boundary"; -import { WhatsNew, whatsNewRoute } from "./components/+whats-new"; import { Notifications } from "./components/notifications"; import { ConfirmDialog } from "./components/confirm-dialog"; import { ExtensionLoader } from "../extensions/extension-loader"; @@ -52,8 +50,6 @@ export class LensApp extends React.Component { - {UserStore.getInstance().isNewVersion && } - diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md deleted file mode 100644 index 11033ac98e..0000000000 --- a/static/RELEASE_NOTES.md +++ /dev/null @@ -1,671 +0,0 @@ -# What's new? - -Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! - -## 5.0.0-alpha.3 (current version) - -- Workspaces are replaced by Catalog & Hotbar -- YAML Templates in Create Resource dock tab -- Add support for viewing 'User-supplied values' of helm release -- Add ability to configure the locale timezone - -## 4.2.1 - -- User is now notified if helm list fails -- Sorting order is now saved when switching views -- Fix: Node shells failing to open -- Fix: Tray icon is now reactive to changes -- Fix: Whole window is used for displaying workspace overview -- Fix: Workspace overview is now reactive to cluster changes -- Fix: Exported ClusterStore now enforces more invariants - -## 4.2.0 - -- Add lens:// protocol handling with a routing mechanism -- Add common app routes to the protocol renderer router from the documentation -- New workspace overview -- New add cluster flow -- Persist Lens UI layout information between restarts. -- Notify about update after it has been downloaded -- Add persistent volumes info to storage class submenu -- Add Pod's image hash as overlay over image name -- Allow to define the path of the shell in app preferences -- Add horizontal scrolling to NamespaceSelect and NamespaceSelectFilter -- Autostart is now always in hidden mode -- Navigation menu in Preferences -- Add terminal clear shortcut for macOS -- Add the ability to hide metrics from the UI -- Add notification to user to add accessible namespaces when needed -- Change Cluster Settings button to be a menu like cluster icon menu -- Fix: Proper sorting resources by age column -- Fix: Events sorting with compact=true is broken -- Fix: Two charts refer to an arbitrary repository -- Fix: Group filtering not working on Custom Resources -- Fix: Font-size on `` -- Fix: Update available notification was able to show twice -- Fix: Cluster-settings page back button navigation is broken -- Fix: Lens not clearing other KUBECONFIG env vars -- Fix: Workspace overview switching and enabled state not being stored storage -- Fix: Extension command palette loading -- Fix: Closing workspace menu after clicking on iframe -- Fix: extension global pages are never able to be visible -- Fix: recreate proxy kubeconfig if it is deleted -- Fix: Proxy should listen only on loopback device -- Fix: Block global path traversal in router -- Fix: Set initial cursor position for the editor to beginning -- Fix: Highlight sidebar's active section - -## 4.1.5 - -- Proxy should listen only on loopback device -- Fix extension command palette loading -- Fix Lens not clearing other KUBECONFIG env vars - -## 4.1.4 - -- Ignore clusters with invalid kubeconfig -- Render only secret name on pod details without access to secrets -- Pass Lens wslenvs to terminal session on Windows -- Prevent top-level re-rendering on cluster refresh -- Extract chart version ignoring numbers in chart name -- The select all checkbox should not select disabled items -- Fix: Pdb should have policy group -- Fix: kubectl rollout not exiting properly on Lens terminal - -## 4.1.3 - -- Don't reset selected namespaces to defaults in case of "All namespaces" on page reload -- Fix loading all namespaces for users with limited cluster access -- Display environment variables coming from secret in pod details -- Fix deprecated helm chart filtering -- Fix RoleBindings Namespace and Bindings field not displaying the correct data -- Fix RoleBindingDetails not rendering the name of the role binding -- Fix auto update on quit with newer version - -## 4.1.2 - -**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. - -- Fix an issue where a cluster gets stuck on "Connecting ..." phase -- Fix an issue with auto-update - -## 4.1.1 - -- Fix an issue where users with rights to a single namespace were seeing an empty dashboard -- Windows: use SHELL for terminal if set -- Keep highlighted table row during navigation in the details panel - -## 4.1.0 - -**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. - -- Change: list views default to a namespace (instead of listing resources from all namespaces) -- Command palette -- Generic logs view with Pod selector -- In-app survey extension -- Auto-update notifications and confirmation -- Possibility to add custom Helm repository through Lens -- Possibility to change visibility of common resource list columns -- Suspend / resume buttons for CronJobs -- Allow namespace to specified on role creation -- Allow for changing installation directory on Windows -- Dock tabs context menu -- Display node column in Pod list -- Unify age column output with kubectl -- Use dark colors in Dock regardless of active theme -- Improve Pod tolerations layout -- Lens metrics: scrape only lens-metrics namespace -- Lens metrics: Prometheus v2.19.3 -- Update bundled kubectl to v1.18.15 -- Improve how watch requests are handled -- Helm rollback window with more details -- Log more on start up -- Export PodDetailsList component to extension API -- Export Wizard components to extension API -- Export NamespaceSelect component to extension API - -## 4.0.8 - -- Fix: extension cluster sub-menu/page periodic re-render -- Fix: app hang on boot if started from command line & oh-my-zsh prompts for auto-update - -## 4.0.7 - -- Fix: typo in Prometheus Ingress metrics -- Fix: catch xterm.js fit error -- Fix: Windows tray icon click -- Fix: error on Kubernetes >= 1.20 on object edit -- Fix: multiline log wrapping -- Fix: prevent clusters from initializing multiple times -- Fix: show default workspace on first boot - -## 4.0.6 - -- Don't open Lens at OS login by default -- Disable GPU acceleration by setting an env variable -- Catch HTTP Errors in case pod metrics resources do not exist or access is forbidden -- Check is persistent volume claims resource to allowed for user -- Share react-router and react-router-dom libraries to extensions -- Fix: long list cropping in sidebar -- Fix: k0s distribution detection -- Fix: Preserve line breaks when copying logs -- Fix: error on api watch on complex api versions - -## 4.0.5 - -- Fix: add missing Kubernetes distro detectors -- Fix: improve how Workloads Overview is loaded -- Fix: race conditions on extension loader -- Fix: pod logs scrolling issues -- Fix: render node list before metrics are available -- Fix: kube-state-metrics v1.9.7 -- Fix: CRD sidebar expand/collapse -- Fix: disable oh-my-zsh auto-update prompt when resolving shell environment -- Add kubectl 1.20 support to Lens Smart Terminal -- Optimise performance during cluster connect - -## 4.0.4 - -- Fix errors on Kubernetes v1.20 -- Update bundled kubectl to v1.17.15 -- Fix: MacOS error on shutdown -- Fix: Kubernetes distribution detection -- Fix: error while displaying CRDs with column which type is an object - -## 4.0.3 - -- Fix: install in-tree extensions before others -- Fix: bundle all dependencies in in-tree extensions -- Fix: display error dialog if extensions couldn't be loaded -- Fix: ensure only one app instance - -## 4.0.2 - -We are aware some users are encountering issues and regressions from previous version. Many of these issues are something we have not seen as part of our automated or manual testing process. To make it worse, some of them are really difficult to reproduce. We want to ensure we are putting all our energy and effort trying to resolve these issues. We hope you are patient. Expect to see new patch releases still in the coming days! Fixes in this version: - -- Fix: use correct apiversion for HPA details -- Fix: use correct apiversion fro CronJob details -- Fix: wrong values in node metrics -- Fix: Deployment scale button "minus" -- Fix: remove symlink on extension install and manual runtime uninstall -- Fix: logs autoscroll behaviour -- Performance fixes - -## 4.0.1 - -- Extension install/uninstall fixes -- Fix status brick styles in pod-menu-extension -- MacOS: fix error on app start -- Performance fix: query all objects using single api call if admin and namespace list is not overridden -- Extension API fix: register a cluster page component properly to a route - -## 4.0.0 - -- Extension API -- Improved pod logs -- Mechanism for users to specify accessible namespaces -- Tray icon -- Support networking.k8s.io/v1 for Ingress -- Add last-status information for container -- Add LoadBalancer information to Ingress view -- Add search by ip to Pod view -- Add Ready status column in the Deployment view -- Add +/- buttons in scale deployment popup screen -- Add stateful set scale slider -- Move tracker to an extension -- Ability to restart deployment -- Status bar visual fixes -- Update chart details when selecting another chart -- Use latest alpine version (3.12) for shell sessions -- Open last active cluster after switching workspaces -- Replace deprecated stable helm repository with bitnami -- Catch errors return error response when fetching chart or chart values fails -- Update EULA url -- Change add-cluster to single column layout -- Replace cluster warning event polling with watches -- Detect more Kubernetes distributions -- Performance fix when cluster has lots of namespaces -- Store more than largest kube api request amount in the event store -- Fix pod usage metrics on Kubernetes >=1.19 -- Fix proxy upgrade socket timeouts -- Fix UI staleness after network issues -- Fix errors on app quit -- Fix kube-auth-proxy to accept only target cluster hostname -- Fix link to metrics stack resources - -## 3.6.9 -- Use Alpine 3.12 for node shell sessions -- Fix errors on app quit -- Fix kube-auth-proxy to accept only target cluster hostname - -## 3.6.8 -- Fix cluster connection issue when opening cluster settings for disconnected clusters -- Fetch available Helm repositories from Artifact HUB -- Check if user is cluster admin before opening cluster dashboard -- Fix issue when application is disconnecting too fast from pod shell -- Fix UI staleness after network issues - -## 3.6.7 -- Fix cluster dashboard opening when cluster is initially offline - -## 3.6.6 -- Fix labels' word boundary to cover only drawer badges -- Fix cluster dashboard opening not to start authentication proxy twice -- Fix: Refresh cluster connection status also when connection is disconnected - -## 3.6.5 -- Prevent drawer close when revealing secret value -- Fix app crash when CRD conditions were not present -- Add support for Stacklight prometheus metrics -- Terminal: set NO_PROXY env for localhost communication -- Fix CPU/Memory usage metrics when using prometheus operator -- Display last-applied-configuration annotation -- Fix Notifications not to block items not visually under them from being interacted with -- Fix side bar not to scroll after clicking on lower menu item -- Auto-select context if only one context is present in pasted Kubeconfig -- Fix background image of What's New page on white theme -- Reduce height on draggable-top and only render it on macos -- Download dir option is now consistent with other settings -- Allow to add the same cluster multiple times -- Convert bytes in memory chart properly -- Fix empty dashboard screen after cluster is removed and added multiple times in a row to application -- Dropdowns have pointer cursor now -- Proxy kubectl exec requests properly -- Pass always chart version information when dealing with helm commands -- Fix app crash when conditions are not yet present in CRD objects -- Fix kubeconfig generating for service account -- Update bundled Helm binary to version 3.3.4 -- Fix clusters' kubeconfig paths that point to snap config dir to use current snap config path - - -## 3.6.4 -- Fix: deleted namespace does not get auto unselected -- Get focus to dock tab (terminal & resource editor) content after resize -- Downloading kubectl binary does not block dashboard opening anymore -- Fix background image of What's New page on white theme - -## 3.6.3 -- Fix app crash on certain situations when opening ingress details -- Reduce app minimum size to support >= 800 x 600 resolution displays -- Fix app crash when service account has imagePullSecrets defined but the actual secret is missing -- Fix words in labels to be selectable either by hovering or double-clicking - -**Known issues** - -- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters. - -## 3.6.2 -- Fix terminal connection opening - -**Known issues** - -- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters. - -## 3.6.1 -- Inject Host header to k8s client requests -- Remove extra refreshEvents polling -- Fix windows installer when app directory removed manually - -**Known issues** - -- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters. - -## 3.6.0 -- Allow user to configure directory where Kubectl binaries are downloaded -- Allow user to configure path to Kubectl binary, instead of using bundled Kubectl -- Allow user to select Kubeconfig from filesystem -- Show the path of the cluster's Kubeconfig in cluster settings -- Store reference to added Kubeconfig files -- Update logo -- Update Kubectl versions used with Lens -- Update Helm binary version -- Add support for PodDisruptionBudgets -- Add port-forwarding for containers in pod -- Add shortcut keys to menu items -- Improve light theme support -- Show GKE ingress IP -- Allow to remove clusters from right click -- Allow to trigger cronjobs -- Show devtools in menu -- Open last active cluster as default -- Log application logs also to log file -- Fix Dialog Esc keypress behavior -- Set new workspace name restrictions -- Fix cluster's apiUrl -- Fix: Cluster dashboard not rendered -- Fix app reload in cluster settings -- Fix proxy kubeconfig file permissions -- Move verbose log lines to silly level -- Add path to auth proxy url if present in cluster url -- Fix path validation message -- Fix: Refresh input values on cluster change -- Fix margins in cluster menu -- Restrict file permissions to only the user for pasted kubeconfigs -- Close Preferences and Cluster Setting on Esc keypress -- Fix: Update CRD api to use preferred version and implement v1 differences -- Fix: Allow to drag and drop cluster icons -- Fix: Wider version select box for Helm chart installation -- Fix: Reload only active dashboard view, not the whole app window -- Fix cluster icon margins -- Fix: Reconnect non-accessible clusters on reconnect -- Fix: Remove double copyright -- Fix: too narrow sidebar without clusters -- Fix app crash when iterating Events without 'kind' property defined -- Detect non-functional bundled kubectl -- Fix format duration rounding days error -- Handle unsupported resources properly after they've been created from editor -- Fix CRD api parsing -- Fix: allow to edit Endpoint resources -- Fix: handle status values that contains an object -- Fix: incorrect path to install/uninstall feature -- Fix: increase timeout when doing port-forward -- Fix: change manifests order for Metrics feature -- Fix: Master donut graph for memory usage only appears to show one master -- Fix: Error during creation of Kubernetes secret -- Fix: Show age of resource in seconds -- Fix: Node shell pods are pending -- Fix: Wrong created time in resource details - -## 3.5.3 -- Updated [EULA](https://k8slens.dev/licenses/eula.md) - -## 3.5.2 -- Fix application not opening properly in some cases by catching and logging error from shell sync. - -## 3.5.1 -- Fix kubernetes api requests to work with non-"namespaces" pathnames -- Fix: Handle invalid metrics responses properly -- Fix: Display namespace defined in kubeconfig always in the namespace selector -- Fix: Make sure that secret is defined before trying to decode -- Update Helm to 3.2.4 -- Fix pasting unicode into terminal sometimes not working -- Fix: Open external links in web browser -- Fix kubectl binary version check - -## 3.5.0 -- Dynamic dashboard UI based on RBAC rules (hides non-accessible menus) -- Show object reference for all objects -- Unify scrollbars/paddings -- New logo -- Remove Helm release update checker -- Improve Helm release version detection -- Show owner reference on all resource details -- Fix: add arch node selector for hybrid clusters -- Fix pod shell command on Windows -- Fix app freeze after closing terminal on Windows -- Fix: use correct kubeconfig context on terminal when switching cluster -- Fix error when closing Lens on Windows -- Fix: deploy kube-state-metrics component only to amd64 nodes -- Translation correction: transit to transmit -- Remove Kontena reference from Lens logo -- Track telemetry pref changed event -- Integration tests using spectron - -## 3.4.0 - -- Auto-detect Prometheus installation -- Allow to select Prometheus query style -- Show node events in node details -- Enable code folding in resource editor -- Improve dashboard reload -- Provide link to configMap from pod details -- Show system roles on Roles page -- Terminal dock tab improvements -- Fix port availability test -- Fix EndpointSubset.toString() to work without ports -- Return empty string if Helm release version is not detected -- Delay webview create on cluster page -- Fix no-drag css -- Fix node shell session regression -- Rebuild locales & fix translation bugs -- Show always Events title in resource details -- Fix missing spaces in container command -- Check also beta.kubernetes.io/os selector for windows pod shell -- Cache terminall shell env -- Cleanup cluster webview loading -- Update metrics feature components -- Update dashboard npm packages - -## 3.3.1 - -- Do not timeout watch requests -- Fix pod shell error if no access to nodes -- Fix list sort by age -- Always refresh stores when object list is mounted -- Update @kubernetes/client-node to 0.11.1 - -## 3.3.0 - -- New section: endpoints -- Initial port-forward implementation for services -- Hide object-list applied filters by default -- Display emptyDir medium and size limit -- Show pod terminating status -- Fix default workspace remove -- Fix issues with crd plugins -- Fix use of bundled kubectl -- Clean up legacy references to Kontena -- Fix jobs sorting if condition is empty -- Electron 6.1.10 - -## 3.2.0 - -- Render colors in logs -- Add kubectl download mirror select to preferences -- Bundle helm3 binary -- Catch ipc errors on proxy exit -- SelfSubjectAccessReview use 'pods' resource -- Send Content-Type header on response for asset request -- Fix Helm chart version comparison -- Don't close namespace menu on select -- Change terminal fit-to-window icon -- Silence terminal websocket connection errors -- Always end watch stream if connection to kube-api ends -- Xterm v4.4.0 - -## 3.1.0 - -- Windows pod shell (powershell) -- Simplified internal architecture (improves watch & metrics stability) -- New icon -- Support `kubernetes.io/role` label for node roles -- Unlink binary download on error properly -- Electron v6.1.9 - -## 3.0.1 - -- Fix an issue with bundled kubectl - -## 3.0.0 - -- Login / signup removed -- Prometheus fixes -- Helm v3.1.2 -- Updated [EULA](https://lakendlabs.com/licenses/lens-eula.md) - -## 2.7.0 - -- Workspaces -- Helm 3 support -- Improved cluster menu -- Snap packaging -- Add setting to allow untrusted certs for external http traffic -- Minor tweaks & bug fixes - -## 2.6.4 - -- Minor bug fixes - -## 2.6.3 - -- Fix kubectl download issue -- Fix terminal missing HTTPS_PROXY environment variable -- Minor bug fixes - -## 2.6.2 - -- Minor bug fixes - -## 2.6.1 - -- Kubernetes watch API reconnect fix -- Minor bug fixes - -## 2.6.0 - -- More clusters supported; Improvements to cluster authentication -- User Interface for CRDs (Custom Resource Definitions) -- Cluster notifications; Display warning events counter on cluster switcher view -- Support for Microsoft Azure AKS + AAD code flow -- Minor bug fixes - -## 2.5.1 - -- Fix cluster add problem on fresh installs - -## 2.5.0 - -- Light theme -- Per cluster HTTP proxy setting -- Load system certificate authorities on MacOS & Windows -- Reorder clusters by dragging -- Improved in-application documentation -- Minor bug fixes - -## 2.4.1 - -- Minor bug fixes. - -## 2.4.0 - -- Allow to configure Prometheus address per cluster -- Allow to configure terminal working directory per cluster -- Improved new user experience: invitation code is not required anymore -- New cluster settings UI -- Fix OIDC with custom CA -- Use configured HTTP proxy for kubectl downloads -- Fix missing icons and fonts for users working offline or behind firewalls -- Minor bug fixes - -## 2.3.2 - -- Minor bug fixes - -## 2.3.1 - -- Minor cluster connection fixes - -## 2.3.0 - -- Massive performance improvements -- Allow to customize cluster icons -- UI for Pod Security Policies -- Support username/password auth type in kubeconfig -- Minor bug fixes - -## 2.2.2 - -- Minor bug fixes - -## 2.2.1 - -- UI performance improvements -- Respect insecure-skip-tls-verification kubeconfig option -- Network timeout tweaks - -## 2.2.0 - -- Allow to configure HTTPS proxy via preferences -- Do not send authorization headers if kubeconfig has client certificate -- Minor UI fixes - -## 2.1.4 - -- OIDC authentication fixes -- Change the local port range from 9000-9900 to 49152-65535 -- Minor UI bug fixes -- Show error details when add cluster fails -- Respect namespace defined in kubeconfig -- Notify about new kube contexts in local kubeconfig - -## 2.1.1 - -- Minor kubeconfig auth-provider fixes. - -## 2.1.0 - -- Don't auto-import kubeconfig -- Allow to import contexts from the default kubeconfig -- Show whats-new page if user running new version of app -- UI performance improvements & minor fixes -- Improved error messages when cluster cannot be accessed -- Improved kubeconfig validation -- Sync environment variables from login shell -- Use node affinity selectors to match OS for metrics pods -- Terminal: zsh fixes -- Terminal: override terminal initscript set KUBECONFIG with Lens provided one -- Handle network issues better -- Menu: show "About Lens" also on Linux & Windows -- Notify if cannot open port on boot -- Improve free port finding -- Sort clusters by name - -## 2.0.9 - -- Wait shell init files are written into the disk -- Use always temp file(s) when applying resources -- Bundle server binaries -- Show errorbox on fatal boot errors -- Let app start, if already logged in, when no networking available - -## 2.0.8 - -- Remove clusters with malformed kubeconfig when initializing clusters -- Show & accept EULA before login -- Download the correct kubectl for 32bit and check kubectl md5sums -- 32bit windows support - -## 2.0.7 - -- Really disable invites when no more left. :) - -## 2.0.6 - -- Remove shell outputs before shell process is started -- Catch kubeconfig load errors better -- Fix app initialization & login timeout cases -- Add Report an Issue to Window menu -- Target linux nodes only in metrics pods - -## 2.0.5 - -- Minor bug fixes. - -## 2.0.4 - -- Enable user invitations in menu -- Better handling for possible errors in kubeconfig authontication -- Kill backend processes on application exit -- Update dashboar UI components to v1.10.0 -- Introduce "User Mode" feature for clusters -- Run login shells in embedded terminals -- Fix cluster settings page scroll issue - -## 2.0.3 - -- Enable persistence for metrics collection only if cluster has default storage class available -- Fix cluster online checking - -## 2.0.2 - -- AppImage Linux application packaging -- Ensure correct version of `kubectl` on terminal shell -- Better error handling for manually added cluster configrations - -## 2.0.1 - -- Add information to request invitation - -## 2.0.0 - -Initial release of the Lens desktop application. Basic functionality with auto-import of users local kubeconfig for cluster access. From 6a702ad19c6804260bdb9629d687c81732fe9f12 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 27 Apr 2021 11:25:06 +0300 Subject: [PATCH 6/8] Hotbar visual improvements (#2638) * Adding hotbar cells Signed-off-by: Alex Andreev * Add/remove empty cells Signed-off-by: Alex Andreev * Increase cell corner radius Signed-off-by: Alex Andreev * Styling hotbar selector Signed-off-by: Alex Andreev * Generating 12 cells by default Signed-off-by: Alex Andreev * Adding custom scrollbar on hover Signed-off-by: Alex Andreev * Reset active cluster when leaving dashboard Signed-off-by: Alex Andreev * Moving kind icon top the top left corner Signed-off-by: Alex Andreev * Highlighting kind icon Signed-off-by: Alex Andreev * Add hotbar cell animations Signed-off-by: Alex Andreev * Adding small hover effect Signed-off-by: Alex Andreev --- .../catalog-entities/kubernetes-cluster.ts | 2 +- src/common/hotbar-store.ts | 59 +++++- src/renderer/components/+catalog/catalog.tsx | 10 +- .../cluster-manager/cluster-manager.tsx | 1 + .../components/hotbar/hotbar-icon.scss | 43 ++-- .../components/hotbar/hotbar-icon.tsx | 20 +- .../components/hotbar/hotbar-menu.scss | 188 ++++++++++++++++-- .../components/hotbar/hotbar-menu.tsx | 104 ++++++++-- 8 files changed, 344 insertions(+), 83 deletions(-) diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 747e042efd..c2dae01b74 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -50,7 +50,7 @@ export class KubernetesCluster extends CatalogEntity { @observable hotbars: Hotbar[] = []; @observable private _activeHotbarId: string; @@ -58,12 +63,16 @@ export class HotbarStore extends BaseStore { return this.hotbars.findIndex((hotbar) => hotbar.id === this.activeHotbarId); } + get initialItems() { + return [...Array.from(Array(defaultHotbarCells).fill(null))]; + } + @action protected async fromStore(data: Partial = {}) { if (data.hotbars?.length === 0) { this.hotbars = [{ id: uuid.v4(), name: "Default", - items: [] + items: this.initialItems, }]; } else { this.hotbars = data.hotbars; @@ -95,7 +104,7 @@ export class HotbarStore extends BaseStore { add(data: HotbarCreateOptions) { const { id = uuid.v4(), - items = [], + items = this.initialItems, name, } = data; @@ -115,6 +124,52 @@ export class HotbarStore extends BaseStore { } } + addToHotbar(item: CatalogEntityItem, cellIndex = -1) { + const hotbar = this.getActive(); + const newItem = { entity: { uid: item.id }}; + + if (hotbar.items.find(i => i?.entity.uid === item.id)) { + return; + } + + if (cellIndex == -1) { + // Add item to empty cell + const emptyCellIndex = hotbar.items.findIndex(isNull); + + if (emptyCellIndex != -1) { + hotbar.items[emptyCellIndex] = newItem; + } else { + // Add new item to the end of list + hotbar.items.push(newItem); + } + } else { + hotbar.items[cellIndex] = newItem; + } + } + + removeFromHotbar(item: CatalogEntity) { + const hotbar = this.getActive(); + const index = hotbar.items.findIndex((i) => i?.entity.uid === item.getId()); + + if (index == -1) { + return; + } + + hotbar.items[index] = null; + } + + addEmptyCell() { + const hotbar = this.getActive(); + + hotbar.items.push(null); + } + + removeEmptyCell(index: number) { + const hotbar = this.getActive(); + + hotbar.items.splice(index, 1); + } + switchToPrevious() { const hotbarStore = HotbarStore.getInstance(); let index = hotbarStore.activeHotbarIndex - 1; diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index b04a83e9c6..e748f27b64 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -46,14 +46,8 @@ export class Catalog extends React.Component { ]); } - addToHotbar(item: CatalogEntityItem) { - const hotbar = HotbarStore.getInstance().getActive(); - - if (!hotbar) { - return; - } - - hotbar.items.push({ entity: { uid: item.id }}); + addToHotbar(item: CatalogEntityItem): void { + HotbarStore.getInstance().addToHotbar(item); } onDetails(item: CatalogEntityItem) { diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 2aa923c402..c09b4d7057 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -28,6 +28,7 @@ export class ClusterManager extends React.Component { reaction(getMatchedClusterId, initView, { fireImmediately: true }), + reaction(() => !getMatchedClusterId(), () => ClusterStore.getInstance().setActive(null)), reaction(() => [ getMatchedClusterId(), // refresh when active cluster-view changed hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded diff --git a/src/renderer/components/hotbar/hotbar-icon.scss b/src/renderer/components/hotbar/hotbar-icon.scss index 913fbc6cb7..6ac4580877 100644 --- a/src/renderer/components/hotbar/hotbar-icon.scss +++ b/src/renderer/components/hotbar/hotbar-icon.scss @@ -2,53 +2,46 @@ .HotbarIcon { --size: 37px; - position: relative; - border-radius: 8px; - padding: 2px; + border-radius: 6px; user-select: none; cursor: pointer; + transition: none; div.MuiAvatar-colorDefault { font-weight:500; text-transform: uppercase; - border-radius: 4px; - } - - div.active { - background-color: var(--primary); - } - - &.interactive { - margin-left: -3px; - border: 3px solid var(--clusterMenuBackground); + border-radius: 6px; } &.active { - border: 3px solid #fff; + box-shadow: 0 0 0px 3px #ffffff; + transition: all 0s 0.8s; } &.active, &.interactive:hover { - - div { - background-color: var(--primary); - } - img { opacity: 1; } } .badge { - color: $textColorAccent; position: absolute; - right: 0; - bottom: 0; - margin: -$padding; - font-size: $font-size-small; - background: $clusterMenuBackground; + right: -2px; + bottom: -3px; + margin: -8px; + font-size: var(--font-size-small); + background: var(--clusterMenuBackground); color: white; padding: 0px; border-radius: 50%; + border: 3px solid var(--clusterMenuBackground); + width: 15px; + height: 15px; + + &.online { + background-color: #44b700; + } + svg { width: 13px; } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 0a6b6f56bb..db2bf6a0dd 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -66,8 +66,8 @@ export class HotbarIcon extends React.Component { ].filter(Boolean).join(""); } - get badgeIcon() { - const className = "badge"; + get kindIcon() { + const className = cssNames("badge", { online: this.props.entity.status.phase == "connected"}); const category = catalogCategoryRegistry.getCategoryForEntity(this.props.entity); if (!category) { @@ -85,14 +85,10 @@ export class HotbarIcon extends React.Component { this.menuOpen = !this.menuOpen; } - removeFromHotbar(item: CatalogEntity) { - const hotbar = HotbarStore.getInstance().getActive(); + remove(item: CatalogEntity) { + const hotbar = HotbarStore.getInstance(); - if (!hotbar) { - return; - } - - hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.metadata.uid); + hotbar.removeFromHotbar(item); } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -146,9 +142,9 @@ export class HotbarIcon extends React.Component { > {this.iconString} - { this.badgeIcon } + { this.kindIcon } { position={{right: true, bottom: true }} // FIXME: position does not work open={() => onOpen()} close={() => this.toggleMenu()}> - this.removeFromHotbar(entity) }> + this.remove(entity) }> Remove from Hotbar { this.contextMenu && menuItems.map((menuItem) => { diff --git a/src/renderer/components/hotbar/hotbar-menu.scss b/src/renderer/components/hotbar/hotbar-menu.scss index e8cf6efc1a..af41e1b95b 100644 --- a/src/renderer/components/hotbar/hotbar-menu.scss +++ b/src/renderer/components/hotbar/hotbar-menu.scss @@ -4,32 +4,194 @@ position: relative; text-align: center; background: $clusterMenuBackground; - border-right: 1px solid $clusterMenuBorderColor; - padding: $spacing 0; - min-width: 75px; + padding-top: 28px; + width: 75px; .is-mac &:before { content: ""; - height: 20px; // extra spacing for mac-os "traffic-light" buttons + height: 4px; // extra spacing for mac-os "traffic-light" buttons } - .items { - padding: 0 $spacing; // extra spacing for cluster-icon's badge - margin-bottom: $margin; - overflow: visible; + &:hover { + .AddCellButton { + opacity: 1; + } + } - &:empty { - display: none; + .HotbarItems { + --cellWidth: 40px; + --cellHeight: 40px; + + box-sizing: content-box; + margin: 0 auto; + height: 100%; + overflow: hidden; + padding-bottom: 8px; + + &:hover { + overflow: overlay; + + &::-webkit-scrollbar { + width: 0.4em; + background: transparent; + z-index: 1; + } + + &::-webkit-scrollbar-thumb { + background: var(--borderFaintColor); + } + } + + .HotbarCell { + width: var(--cellWidth); + height: var(--cellHeight); + min-height: var(--cellHeight); + margin: 12px; + background: var(--layoutBackground); + border-radius: 6px; + position: relative; + transform: translateZ(0); // Remove flickering artifacts + + &:hover { + .cellDeleteButton { + opacity: 1; + transition: opacity 0.1s 0.2s; + } + + &:not(.empty) { + box-shadow: 0 0 0px 3px #ffffff1a; + } + } + + &.animating { + &.empty { + animation: shake .6s cubic-bezier(.36,.07,.19,.97) both; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + perspective: 1000px; + } + + &:not(.empty) { + animation: outline 0.8s cubic-bezier(0.19, 1, 0.22, 1); + } + } + + .cellDeleteButton { + width: 2rem; + height: 2rem; + border-radius: 50%; + background-color: var(--textColorDimmed); + position: absolute; + top: -7px; + right: -7px; + color: var(--secondaryBackground); + opacity: 0; + border: 3px solid var(--clusterMenuBackground); + box-sizing: border-box; + + &:hover { + background-color: white; + transition: all 0.2s; + } + + .Icon { + --smallest-size: 12px; + font-weight: bold; + position: relative; + top: -2px; + left: .5px; + } + } } } .HotbarSelector { - position: absolute; - bottom: 0; - width: 100%; + height: 26px; + background-color: var(--layoutBackground); + position: relative; + + &:before { + content: " "; + position: absolute; + width: 100%; + height: 20px; + background: linear-gradient(0deg, var(--clusterMenuBackground), transparent); + top: -20px; + } .Badge { cursor: pointer; + background: var(--secondaryBackground); + width: 100%; + color: var(--settingsColor); + padding-top: 3px; + } + + .Icon { + --size: 16px; + padding: 0 4px; + + &:hover { + box-shadow: none; + background-color: transparent; + } + + &.previous { + transform: rotateY(180deg); + } + } + } + + .AddCellButton { + width: 40px; + height: 40px; + min-height: 40px; + margin: 12px auto 8px; + background-color: transparent; + color: var(--textColorDimmed); + border-radius: 6px; + transition: all 0.2s; + cursor: pointer; + z-index: 1; + opacity: 0; + transition: all 0.2s; + + &:hover { + background-color: var(--sidebarBackground); + } + + .Icon { + --size: 24px; + margin-left: 2px; } } } + +@keyframes shake { + 10%, 90% { + transform: translate3d(-1px, 0, 0); + } + + 20%, 80% { + transform: translate3d(2px, 0, 0); + } + + 30%, 50%, 70% { + transform: translate3d(-4px, 0, 0); + } + + 40%, 60% { + transform: translate3d(4px, 0, 0); + } +} + +// TODO: Use theme-aware colors +@keyframes outline { + 0% { + box-shadow: 0 0 0px 11px $clusterMenuBackground, 0 0 0px 15px #ffffff00; + } + + 100% { + box-shadow: 0 0 0px 0px $clusterMenuBackground, 0 0 0px 3px #ffffff; + } +} \ No newline at end of file diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index ac99a45226..931ebc02e8 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -1,17 +1,18 @@ import "./hotbar-menu.scss"; import "./hotbar.commands"; -import React from "react"; +import React, { ReactNode, useState } from "react"; import { observer } from "mobx-react"; import { HotbarIcon } from "./hotbar-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { catalogEntityRunContext } from "../../api/catalog-entity"; +import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store"; +import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; import { Icon } from "../icon"; import { Badge } from "../badge"; import { CommandOverlay } from "../command-palette"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; +import { ClusterStore } from "../../../common/cluster-store"; import { Tooltip, TooltipPosition } from "../tooltip"; interface Props { @@ -20,14 +21,22 @@ interface Props { @observer export class HotbarMenu extends React.Component { - get hotbarItems() { + get hotbar() { + return HotbarStore.getInstance().getActive(); + } + + isActive(item: CatalogEntity) { + return ClusterStore.getInstance().activeClusterId == item.getId(); + } + + getEntity(item: HotbarItem) { const hotbar = HotbarStore.getInstance().getActive(); if (!hotbar) { - return []; + return null; } - return hotbar.items.map((item) => catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid)).filter(Boolean); + return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null; } previous() { @@ -42,6 +51,36 @@ export class HotbarMenu extends React.Component { CommandOverlay.open(); } + renderGrid() { + if (!this.hotbar.items.length) return; + + return this.hotbar.items.map((item, index) => { + const entity = this.getEntity(item); + + return ( + + {entity && ( + entity.onRun(catalogEntityRunContext)} + /> + )} + + ); + }); + } + + renderAddCellButton() { + return ( + + ); + } + render() { const { className } = this.props; const hotbarStore = HotbarStore.getInstance(); @@ -50,22 +89,13 @@ export class HotbarMenu extends React.Component { return (
-
- {this.hotbarItems.map((entity, index) => { - return ( - entity.onRun(catalogEntityRunContext)} - /> - ); - })} +
+ {this.renderGrid()} + {this.hotbar.items.length != defaultHotbarCells && this.renderAddCellButton()}
-
- this.previous()} /> -
+
+ this.previous()} /> +
this.openSelector()} /> { {hotbar.name}
- this.next()} /> + this.next()} />
); } } + +interface HotbarCellProps { + children?: ReactNode; + index: number; +} + +function HotbarCell(props: HotbarCellProps) { + const [animating, setAnimating] = useState(false); + const onAnimationEnd = () => { setAnimating(false); }; + const onClick = () => { setAnimating(true); }; + const onDeleteClick = (evt: Event | React.SyntheticEvent) => { + evt.stopPropagation(); + HotbarStore.getInstance().removeEmptyCell(props.index); + }; + + return ( +
+ {props.children} + {!props.children && ( +
+ +
+ )} +
+ ); +} From 9dad08c45f4ae5e68763766b0cb5aa6a6cb259fd Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 27 Apr 2021 04:27:37 -0400 Subject: [PATCH 7/8] Asyncronously recompute the placement of an open (#2631) * Asyncronously recompute the placement of an open Signed-off-by: Sebastian Malton * fix refreshPosition Signed-off-by: Sebastian Malton --- src/renderer/components/menu/menu.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/menu/menu.tsx b/src/renderer/components/menu/menu.tsx index 5e070698dd..f0d172b0d7 100644 --- a/src/renderer/components/menu/menu.tsx +++ b/src/renderer/components/menu/menu.tsx @@ -5,7 +5,6 @@ import { createPortal } from "react-dom"; import { autobind, cssNames, noop } from "../../utils"; import { Animate } from "../animate"; import { Icon, IconProps } from "../icon"; -import debounce from "lodash/debounce"; export const MenuContext = React.createContext(null); export type MenuContextValue = Menu; @@ -122,8 +121,11 @@ export class Menu extends React.Component { } } - refreshPosition = debounce(() => { - if (!this.props.usePortal || !this.opener) return; + refreshPosition = () => { + if (!this.props.usePortal || !this.opener || !this.elem) { + return; + } + const { width, height } = this.opener.getBoundingClientRect(); let { left, top, bottom, right } = this.opener.getBoundingClientRect(); const withScroll = window.getComputedStyle(this.elem).position !== "fixed"; @@ -157,7 +159,7 @@ export class Menu extends React.Component { delete position.bottom; } this.setState({ position }); - }, Animate.VISIBILITY_DELAY_MS); + }; open() { if (this.isOpen) return; @@ -248,6 +250,10 @@ export class Menu extends React.Component { } render() { + if (this.isOpen) { + setImmediate(() => this.refreshPosition()); + } + const { position, id } = this.props; let { className, usePortal } = this.props; From 2ba0a90cb5fdadfbdb6e17abffd8ba5290fa15a5 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 27 Apr 2021 11:28:00 +0300 Subject: [PATCH 8/8] Show cluster metrics settings always (#2635) Signed-off-by: Jari Kolehmainen --- src/renderer/components/cluster-settings/cluster-settings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index 247e098126..5ebde4caa8 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -112,7 +112,6 @@ entitySettingRegistry.add([ { apiVersions: ["entity.k8slens.dev/v1alpha1"], kind: "KubernetesCluster", - source: "local", title: "Metrics", components: { View: (props: { entity: CatalogEntity }) => {