diff --git a/.github/workflows/mkdocs-manual.yml b/.github/workflows/mkdocs-manual.yml new file mode 100644 index 0000000000..632fccfa7b --- /dev/null +++ b/.github/workflows/mkdocs-manual.yml @@ -0,0 +1,67 @@ +name: Manual documentation update to sync a deployed Version with Master branch +on: + workflow_dispatch: + inputs: + version: + description: 'Version string to use (e.g."v0.0.1")' + required: true +jobs: + build: + name: Manual documentation update to sync a deployed Version with Master branch + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - name: Set up Python 3.7 + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install git+https://${{ secrets.GH_TOKEN }}@github.com/lensapp/mkdocs-material-insiders.git + pip install mike + + - name: Checkout Version from lens + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: '${{ github.event.inputs.version }}' + + - name: Using Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Generate Extensions API Reference using typedocs + run: | + yarn install + yarn typedocs-extensions-api + + - name: Checkout master branch from lens + uses: actions/checkout@v2 + with: + path: 'master' + ref: 'master' + + - name: Bring in latest mkdocs.yml from master + run: | + cp -p ./master/mkdocs.yml . + cp -p ./master/docs/stylesheets/extra.css ./docs/stylesheets/extra.css + rm -fr ./docs/clusters ./docs/contributing ./docs/faq ./docs/getting-started ./docs/helm ./docs/support ./docs/supporting + sed -i '/Protocol Handlers/d' ./mkdocs.yml + sed -i '/IPC/d' ./mkdocs.yml + sed -i 's#../../clusters/adding-clusters.md#https://docs.k8slens.dev/latest/clusters/adding-clusters/#g' ./docs/extensions/get-started/your-first-extension.md + sed -i 's#clusters/adding-clusters.md#https://docs.k8slens.dev/latest/clusters/adding-clusters/#g' ./docs/README.md + sed -i 's#../../contributing/README.md#https://docs.k8slens.dev/latest/contributing/#g' ./docs/extensions/guides/generator.md + + - name: git config + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + - name: mkdocs deploy new release + run: | + mike deploy --push --force ${{ github.event.inputs.version }} diff --git a/Makefile b/Makefile index fba4e9b98e..f2c9d0eb13 100644 --- a/Makefile +++ b/Makefile @@ -68,8 +68,9 @@ integration-win: binaries/client build-extension-types build-extensions yarn integration .PHONY: build -build: node_modules binaries/client build-extensions +build: node_modules binaries/client yarn run npm:fix-build-version + $(MAKE) build-extensions yarn run compile ifeq "$(DETECTED_OS)" "Windows" yarn run electron-builder --publish onTag --x64 --ia32 diff --git a/mkdocs.yml b/mkdocs.yml index eea8ffc5ea..7ef283c348 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: Lens Extension API -site_description: Documentation for Lens Extension API. +site_name: Lens Extension Development +site_description: Documentation for Lens Extension Development and API. site_author: Mirantis, Inc. site_url: https://api-docs.k8slens.dev docs_dir: docs/ @@ -12,28 +12,27 @@ google_analytics: - auto nav: - Overview: README.md - - Extension Development: - - Getting Started: - - Overview: extensions/get-started/overview.md - - Your First Extension: extensions/get-started/your-first-extension.md - - Extension Anatomy: extensions/get-started/anatomy.md - - Wrapping Up: extensions/get-started/wrapping-up.md - - Extension Capabilities: - - Common Capabilities: extensions/capabilities/common-capabilities.md - - Styling: extensions/capabilities/styling.md - - Extension Guides: - - Overview: extensions/guides/README.md - - Generator: extensions/guides/generator.md - - Main Extension: extensions/guides/main-extension.md - - Renderer Extension: extensions/guides/renderer-extension.md - - Stores: extensions/guides/stores.md - - Working with MobX: extensions/guides/working-with-mobx.md - - Protocol Handlers: extensions/guides/protocol-handlers.md - - IPC: extensions/guides/ipc.md - - Testing and Publishing: - - Testing Extensions: extensions/testing-and-publishing/testing.md - - Publishing Extensions: extensions/testing-and-publishing/publishing.md - - API Reference: extensions/api/README.md + - Getting Started: + - Overview: extensions/get-started/overview.md + - Your First Extension: extensions/get-started/your-first-extension.md + - Extension Anatomy: extensions/get-started/anatomy.md + - Wrapping Up: extensions/get-started/wrapping-up.md + - Extension Capabilities: + - Common Capabilities: extensions/capabilities/common-capabilities.md + - Styling: extensions/capabilities/styling.md + - Extension Guides: + - Overview: extensions/guides/README.md + - Generator: extensions/guides/generator.md + - Main Extension: extensions/guides/main-extension.md + - Renderer Extension: extensions/guides/renderer-extension.md + - Stores: extensions/guides/stores.md + - Working with MobX: extensions/guides/working-with-mobx.md + - Protocol Handlers: extensions/guides/protocol-handlers.md + - IPC: extensions/guides/ipc.md + - Testing and Publishing: + - Testing Extensions: extensions/testing-and-publishing/testing.md + - Publishing Extensions: extensions/testing-and-publishing/publishing.md + - API Reference: extensions/api/README.md theme: name: 'material' highlightjs: true diff --git a/package.json b/package.json index 1d96894bb9..0945f0799a 100644 --- a/package.json +++ b/package.json @@ -250,7 +250,7 @@ "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.57", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@testing-library/jest-dom": "^5.11.10", + "@testing-library/jest-dom": "^5.13.0", "@testing-library/react": "^11.2.6", "@types/byline": "^4.2.32", "@types/chart.js": "^2.9.21", @@ -312,7 +312,7 @@ "color": "^3.1.2", "concurrently": "^5.2.0", "css-element-queries": "^1.2.3", - "css-loader": "^3.5.3", + "css-loader": "^5.2.6", "deepdash": "^5.3.5", "dompurify": "^2.0.11", "electron": "^12.0.10", diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh index 3a32bd3189..82b7f14e6c 100755 --- a/scripts/tag-release.sh +++ b/scripts/tag-release.sh @@ -1,6 +1,6 @@ #!/bin/bash -if [[ ${git branch --show-current} =~ ^release/v ]] +if [[ `git branch --show-current` =~ ^release/v ]] then VERSION_STRING=$(cat package.json | jq '.version' -r | xargs printf "v%s") git tag ${VERSION_STRING} diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 67d82e3501..2781c5dfb0 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -101,20 +101,18 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) - }, - ]; + }); + } if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { context.menuItems.push({ title: "Delete", icon: "delete", - onlyVisibleForSource: "local", onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), confirm: { message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?` diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 6c58497204..9acc2a47c9 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -104,10 +104,6 @@ export interface CatalogEntityContextMenu { * Menu icon */ icon?: string; - /** - * Show only if empty or if value matches with entity.metadata.source - */ - onlyVisibleForSource?: string; /** * OnClick handler */ diff --git a/src/common/utils/__tests__/hash-set.test.ts b/src/common/utils/__tests__/hash-set.test.ts new file mode 100644 index 0000000000..38614b7d3e --- /dev/null +++ b/src/common/utils/__tests__/hash-set.test.ts @@ -0,0 +1,528 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { HashSet, ObservableHashSet } from "../hash-set"; + +describe("ObservableHashSet", () => { + it("should not throw on creation", () => { + expect(() => new ObservableHashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError(); + }); + + it("should be initializable", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + }); + + it("has should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("forEach should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + let a = 1; + + res.forEach((item) => { + expect(item.a).toEqual(a++); + }); + }); + + it("delete should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.delete({ a: 1 })).toBe(true); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 5 })).toBe(false); + expect(res.delete({ a: 5 })).toBe(false); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("toggle should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.toggle({ a: 1 }); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 6 })).toBe(false); + res.toggle({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 6 })).toBe(false); + res.add({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should treat the hash to be the same as equality", () => { + const res = new ObservableHashSet([ + { a: 1, foobar: "hello" }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.add({ a: 1, foobar: "goodbye" }); + expect(res.has({ a: 1 })).toBe(true); + }); + + it("clear should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.clear(); + expect(res.size).toBe(0); + }); + + it("replace should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.replace([{ a: 13 }]); + expect(res.size).toBe(1); + expect(res.has({ a: 1 })).toBe(false); + expect(res.has({ a: 13 })).toBe(true); + }); + + it("toJSON should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.toJSON()).toStrictEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]); + }); + + it("values should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.values(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("keys should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.keys(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("entries should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.entries(); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 1 }, { a: 1 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 2 }, { a: 2 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 3 }, { a: 3 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 4 }, { a: 4 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); +}); + +describe("HashSet", () => { + it("should not throw on creation", () => { + expect(() => new HashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError(); + }); + + it("should be initializable", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + }); + + it("has should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("forEach should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + let a = 1; + + res.forEach((item) => { + expect(item.a).toEqual(a++); + }); + }); + + it("delete should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.delete({ a: 1 })).toBe(true); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 5 })).toBe(false); + expect(res.delete({ a: 5 })).toBe(false); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("toggle should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.toggle({ a: 1 }); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 6 })).toBe(false); + res.toggle({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 6 })).toBe(false); + res.add({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should treat the hash to be the same as equality", () => { + const res = new HashSet([ + { a: 1, foobar: "hello" }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.add({ a: 1, foobar: "goodbye" }); + expect(res.has({ a: 1 })).toBe(true); + }); + + it("clear should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.clear(); + expect(res.size).toBe(0); + }); + + it("replace should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.replace([{ a: 13 }]); + expect(res.size).toBe(1); + expect(res.has({ a: 1 })).toBe(false); + expect(res.has({ a: 13 })).toBe(true); + }); + + it("toJSON should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.toJSON()).toStrictEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]); + }); + + it("values should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.values(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("keys should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.keys(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("entries should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.entries(); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 1 }, { a: 1 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 2 }, { a: 2 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 3 }, { a: 3 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 4 }, { a: 4 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); +}); diff --git a/src/common/utils/__tests__/n-fircate.test.ts b/src/common/utils/__tests__/n-fircate.test.ts new file mode 100644 index 0000000000..ad502d05e4 --- /dev/null +++ b/src/common/utils/__tests__/n-fircate.test.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { nFircate } from "../n-fircate"; + +describe("nFircate", () => { + it("should produce an empty array if no parts are provided", () => { + expect(nFircate([{ a: 1 }, { a: 2 }], "a", []).length).toBe(0); + }); + + it("should ignore non-matching parts", () => { + const res = nFircate([{ a: 1 }, { a: 2 }], "a", [1]); + + expect(res.length).toBe(1); + expect(res[0].length).toBe(1); + }); + + it("should include all matching parts in each type", () => { + const res = nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2]); + + expect(res.length).toBe(2); + expect(res[0].length).toBe(2); + expect(res[0][0].b).toBe("a"); + expect(res[0][1].b).toBe("c"); + expect(res[1].length).toBe(1); + expect(res[1][0].b).toBe("b"); + }); + + it("should throw a type error if the same part is provided more than once", () => { + try { + nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2, 1]); + fail("Expected error"); + } catch (error) { + expect(error).toBeInstanceOf(TypeError); + } + }); +}); diff --git a/src/common/utils/hash-set.ts b/src/common/utils/hash-set.ts new file mode 100644 index 0000000000..87fa8daa4c --- /dev/null +++ b/src/common/utils/hash-set.ts @@ -0,0 +1,260 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { action, IInterceptable, IInterceptor, IListenable, ISetWillChange, observable, ObservableMap, ObservableSet } from "mobx"; + +export function makeIterableIterator(iterator: Iterator): IterableIterator { + (iterator as IterableIterator)[Symbol.iterator] = () => iterator as IterableIterator; + + return iterator as IterableIterator; +} + +export class HashSet implements Set { + #hashmap: Map; + + constructor(initialValues: Iterable, protected hasher: (item: T) => string) { + this.#hashmap = new Map(Array.from(initialValues, value => [this.hasher(value), value])); + } + + replace(other: ObservableHashSet | ObservableSet | Set | readonly T[]): this { + if (other === null || other === undefined) { + return this; + } + + if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + this.clear(); + + for (const value of other) { + this.add(value); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + toggle(value: T): void { + const hash = this.hasher(value); + + if (this.#hashmap.has(hash)) { + this.#hashmap.delete(hash); + } else { + this.#hashmap.set(hash, value); + } + } + + delete(value: T): boolean { + return this.#hashmap.delete(this.hasher(value)); + } + + forEach(callbackfn: (value: T, key: T, set: Set) => void, thisArg?: any): void { + this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this)); + } + + has(value: T): boolean { + return this.#hashmap.has(this.hasher(value)); + } + + get size(): number { + return this.#hashmap.size; + } + + entries(): IterableIterator<[T, T]> { + let nextIndex = 0; + const keys = Array.from(this.keys()); + const values = Array.from(this.values()); + + return makeIterableIterator<[T, T]>({ + next() { + const index = nextIndex++; + + return index < values.length + ? { value: [keys[index], values[index]], done: false } + : { done: true, value: undefined }; + } + }); + } + + keys(): IterableIterator { + return this.values(); + } + + values(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterableIterator({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++], done: false } + : { done: true, value: undefined }; + } + }); + } + + [Symbol.iterator](): IterableIterator { + return this.#hashmap.values(); + } + + get [Symbol.toStringTag](): string { + return "Set"; + } + + toJSON(): T[] { + return Array.from(this); + } + + toString(): string { + return "[object Set]"; + } +} + +export class ObservableHashSet implements Set, IInterceptable, IListenable { + #hashmap: ObservableMap; + + get interceptors_(): IInterceptor>[] { + return []; + } + + get changeListeners_(): Function[] { + return []; + } + + constructor(initialValues: Iterable, protected hasher: (item: T) => string) { + this.#hashmap = observable.map(Array.from(initialValues, value => [this.hasher(value), value]), undefined); + } + + @action + replace(other: ObservableHashSet | ObservableSet | Set | readonly T[]): this { + if (other === null || other === undefined) { + return this; + } + + if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + this.clear(); + + for (const value of other) { + this.add(value); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + @action + toggle(value: T): void { + const hash = this.hasher(value); + + if (this.#hashmap.has(hash)) { + this.#hashmap.delete(hash); + } else { + this.#hashmap.set(hash, value); + } + } + + delete(value: T): boolean { + return this.#hashmap.delete(this.hasher(value)); + } + + forEach(callbackfn: (value: T, key: T, set: Set) => void, thisArg?: any): void { + this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this)); + } + + has(value: T): boolean { + return this.#hashmap.has(this.hasher(value)); + } + + get size(): number { + return this.#hashmap.size; + } + + entries(): IterableIterator<[T, T]> { + let nextIndex = 0; + const keys = Array.from(this.keys()); + const values = Array.from(this.values()); + + return makeIterableIterator<[T, T]>({ + next() { + const index = nextIndex++; + + return index < values.length + ? { value: [keys[index], values[index]], done: false } + : { done: true, value: undefined }; + } + }); + } + + keys(): IterableIterator { + return this.values(); + } + + values(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterableIterator({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++], done: false } + : { done: true, value: undefined }; + } + }); + } + + [Symbol.iterator](): IterableIterator { + return this.#hashmap.values(); + } + + get [Symbol.toStringTag](): string { + return "Set"; + } + + toJSON(): T[] { + return Array.from(this); + } + + toString(): string { + return "[object ObservableSet]"; + } +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index cc33ab332e..34bd65755b 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -29,17 +29,17 @@ export * from "./app-version"; export * from "./autobind"; export * from "./base64"; export * from "./camelCase"; -export * from "./toJS"; export * from "./cloneJson"; export * from "./debouncePromise"; export * from "./defineGlobal"; export * from "./delay"; export * from "./disposer"; -export * from "./disposer"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./extended-map"; export * from "./getRandId"; +export * from "./hash-set"; +export * from "./n-fircate"; export * from "./openExternal"; export * from "./paths"; export * from "./reject-promise"; @@ -47,6 +47,7 @@ export * from "./singleton"; export * from "./splitArray"; export * from "./tar"; export * from "./toggle-set"; +export * from "./toJS"; export * from "./type-narrowing"; import * as iter from "./iter"; diff --git a/src/common/utils/n-fircate.ts b/src/common/utils/n-fircate.ts new file mode 100644 index 0000000000..289d391e3e --- /dev/null +++ b/src/common/utils/n-fircate.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Split an iterable into several arrays with matching fields + * @param from The iterable of items to split up + * @param field The field of each item to split over + * @param parts What each array will be filtered to + * @returns A `parts.length` tuple of `T[]` where each array has matching `field` values + */ +export function nFircate(from: Iterable, field: keyof T, parts: []): []; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field]]): [T[]]; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field], T[typeof field]]): [T[], T[]]; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field], T[typeof field], T[typeof field]]): [T[], T[], T[]]; + +export function nFircate(from: Iterable, field: keyof T, parts: T[typeof field][]): T[][] { + if (new Set(parts).size !== parts.length) { + throw new TypeError("Duplicate parts entries"); + } + + const res = Array.from(parts, () => [] as T[]); + + for (const item of from) { + const index = parts.indexOf(item[field]); + + if (index < 0) { + continue; + } + + res[index].push(item); + } + + return res; +} diff --git a/src/common/vars.ts b/src/common/vars.ts index b8aa69e4d5..a49c8da910 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -21,6 +21,7 @@ // 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"; @@ -66,4 +67,6 @@ export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string; export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string; + +export const appSemVer = new SemVer(packageInfo.version); export const docsUrl = `https://docs.k8slens.dev/main/` as string; diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index 5663a1c13a..347487e556 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -93,6 +93,7 @@ describe("ExtensionDiscovery", () => { id: path.normalize("node_modules/my-extension/package.json"), isBundled: false, isEnabled: false, + isCompatible: false, manifest: { name: "my-extension", }, diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index 57ae8b8a68..c9c52d1637 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -38,7 +38,8 @@ describe("lens extension", () => { absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, - isEnabled: true + isEnabled: true, + isCompatible: true }); }); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 829e8a617f..bfe8dc1536 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -30,10 +30,13 @@ import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } fr import { Singleton, toJS } from "../common/utils"; import logger from "../main/logger"; import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store"; -import { extensionInstaller, PackageJson } from "./extension-installer"; +import { extensionInstaller } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; import { ExtensionLoader } from "./extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; +import type { PackageJson } from "type-fest"; +import semver from "semver"; +import { appSemVer } from "../common/vars"; import { isProduction } from "../common/vars"; export interface InstalledExtension { @@ -48,6 +51,7 @@ export interface InstalledExtension { // Absolute to the symlinked package.json file readonly manifestPath: string; readonly isBundled: boolean; // defined in project root's package.json + readonly isCompatible: boolean; isEnabled: boolean; } @@ -349,12 +353,17 @@ export class ExtensionDiscovery extends Singleton { */ protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { try { - const manifest = await fse.readJson(manifestPath); + const manifest = await fse.readJson(manifestPath) as LensExtensionManifest; const installedManifestPath = this.getInstalledManifestPath(manifest.name); const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); const extensionDir = path.dirname(manifestPath); const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; + let isCompatible = isBundled; + + if (manifest.engines?.lens) { + isCompatible = semver.satisfies(appSemVer, manifest.engines.lens); + } return { id: installedManifestPath, @@ -362,7 +371,8 @@ export class ExtensionDiscovery extends Singleton { manifestPath: installedManifestPath, manifest, isBundled, - isEnabled + isEnabled, + isCompatible }; } catch (error) { if (error.code === "ENOTDIR") { diff --git a/src/extensions/extension-installer.ts b/src/extensions/extension-installer.ts index 797da91c5f..57e67183bf 100644 --- a/src/extensions/extension-installer.ts +++ b/src/extensions/extension-installer.ts @@ -25,17 +25,10 @@ import fs from "fs-extra"; import path from "path"; import logger from "../main/logger"; import { extensionPackagesRoot } from "./extension-loader"; +import type { PackageJson } from "type-fest"; const logModule = "[EXTENSION-INSTALLER]"; -type Dependencies = { - [name: string]: string; -}; - -// Type for the package.json file that is written by ExtensionInstaller -export type PackageJson = { - dependencies: Dependencies; -}; /** * Installs dependencies for extensions diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index fc8ba332b1..79b61cf213 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -303,7 +303,7 @@ export class ExtensionLoader extends Singleton { for (const [extId, extension] of installedExtensions) { const alreadyInit = this.instances.has(extId); - if (extension.isEnabled && !alreadyInit) { + if (extension.isCompatible && extension.isEnabled && !alreadyInit) { try { const LensExtensionClass = this.requireExtension(extension); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index f3910308fd..ac77a3b229 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -24,18 +24,17 @@ import { action, observable, makeObservable } from "mobx"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; import type { ProtocolHandlerRegistration } from "./registries"; +import type { PackageJson } from "type-fest"; import { Disposer, disposer } from "../common/utils"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; -export interface LensExtensionManifest { +export interface LensExtensionManifest extends PackageJson { name: string; version: string; - description?: string; main?: string; // path to %ext/dist/main.js renderer?: string; // path to %ext/dist/renderer.js - lens?: object; // fixme: add more required fields for validation } export const Disposers = Symbol(); @@ -91,7 +90,7 @@ export class LensExtension { try { await this.onActivate(); this.isEnabled = true; - + this[Disposers].push(...await register(this)); logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); } catch (error) { diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 8c6582b437..497d92e723 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -40,7 +40,8 @@ describe("getPageUrl", () => { absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, - isEnabled: true + isEnabled: true, + isCompatible: true }); globalPageRegistry.add({ id: "page-with-params", @@ -107,7 +108,8 @@ describe("globalPageRegistry", () => { absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, - isEnabled: true + isEnabled: true, + isCompatible: true }); globalPageRegistry.add([ { diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index d16dbe83f5..c579aafbb6 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -86,8 +86,8 @@ export type { PersistentVolumesStore } from "../../renderer/components/+storage- export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store"; export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace.store"; -export type { ServiceAccountsStore } from "../../renderer/components/+user-management-service-accounts/service-accounts.store"; -export type { RolesStore } from "../../renderer/components/+user-management-roles/roles.store"; -export type { RoleBindingsStore } from "../../renderer/components/+user-management-roles-bindings/role-bindings.store"; +export type { ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; +export type { RolesStore } from "../../renderer/components/+user-management/+roles/store"; +export type { RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store"; export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store"; diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 597be0c124..6c1d9e777a 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -87,6 +87,7 @@ describe("protocol router tests", () => { }, isBundled: false, isEnabled: true, + isCompatible: true, absolutePath: "/foo/bar", }); const lpr = LensProtocolRouterMain.getInstance(); @@ -165,6 +166,7 @@ describe("protocol router tests", () => { }, isBundled: false, isEnabled: true, + isCompatible: true, absolutePath: "/foo/bar", }); @@ -206,6 +208,7 @@ describe("protocol router tests", () => { }, isBundled: false, isEnabled: true, + isCompatible: true, absolutePath: "/foo/bar", }); @@ -230,6 +233,7 @@ describe("protocol router tests", () => { }, isBundled: false, isEnabled: true, + isCompatible: true, absolutePath: "/foo/bar", }); diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index eaa95f0fd8..039caa44ce 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -22,7 +22,7 @@ import type { Cluster } from "../cluster"; import { Kubectl } from "../kubectl"; import type * as WebSocket from "ws"; -import shellEnv from "shell-env"; +import { shellEnv } from "../utils/shell-env"; import { app } from "electron"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; import path from "path"; diff --git a/src/main/shell-sync.ts b/src/main/shell-sync.ts index ff38ede3bb..d699969224 100644 --- a/src/main/shell-sync.ts +++ b/src/main/shell-sync.ts @@ -19,10 +19,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import shellEnv from "shell-env"; +import { shellEnv } from "./utils/shell-env"; import os from "os"; import { app } from "electron"; -import logger from "./logger"; interface Env { [key: string]: string; @@ -37,16 +36,7 @@ export async function shellSync() { const { shell } = os.userInfo(); let envVars = {}; - try { - envVars = await Promise.race([ - shellEnv(shell), - new Promise((_resolve, reject) => setTimeout(() => { - reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); - }, 5_000)) - ]); - } catch (error) { - logger.error(`shellEnv: ${error}`); - } + envVars = await shellEnv(shell); const env: Env = JSON.parse(JSON.stringify(envVars)); diff --git a/src/main/utils/shell-env.ts b/src/main/utils/shell-env.ts new file mode 100644 index 0000000000..b544603075 --- /dev/null +++ b/src/main/utils/shell-env.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import shellEnvironment from "shell-env"; +import logger from "../logger"; + +export interface EnvironmentVariables { + readonly [key: string]: string; +} + +let shellSyncFailed = false; + +/** + * Attempts to get the shell environment per the user's existing startup scripts. + * If the environment can't be retrieved after 5 seconds an error message is logged. + * Subsequent calls after such a timeout simply log an error message without trying + * to get the environment, unless forceRetry is true. + * @param shell the shell to get the environment from + * @param forceRetry if true will always try to get the environment, otherwise if + * a previous call to this function failed then this call will fail too. + * @returns object containing the shell's environment variables. An empty object is + * returned if the call fails. + */ +export async function shellEnv(shell?: string, forceRetry = false) : Promise { + let envVars = {}; + + if (forceRetry) { + shellSyncFailed = false; + } + + if (!shellSyncFailed) { + try { + envVars = await Promise.race([ + shellEnvironment(shell), + new Promise((_resolve, reject) => setTimeout(() => { + reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); + }, 5_000)) + ]); + } catch (error) { + logger.error(`shellEnv: ${error}`); + shellSyncFailed = true; + } + } else { + logger.error("shellSync(): Resolving shell environment took too long. Please review your shell configuration."); + } + + return envVars; +} diff --git a/src/renderer/api/endpoints/cluster-role-binding.api.ts b/src/renderer/api/endpoints/cluster-role-binding.api.ts index 6f165316ef..868f00261a 100644 --- a/src/renderer/api/endpoints/cluster-role-binding.api.ts +++ b/src/renderer/api/endpoints/cluster-role-binding.api.ts @@ -18,14 +18,39 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { RoleBinding } from "./role-binding.api"; import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; -export class ClusterRoleBinding extends RoleBinding { +export type ClusterRoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface ClusterRoleBindingSubject { + kind: ClusterRoleBindingSubjectKind; + name: string; + apiGroup?: string; + namespace?: string; +} + +export interface ClusterRoleBinding { + subjects?: ClusterRoleBindingSubject[]; + roleRef: { + kind: string; + name: string; + apiGroup?: string; + }; +} + +export class ClusterRoleBinding extends KubeObject { static kind = "ClusterRoleBinding"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"; + + getSubjects() { + return this.subjects || []; + } + + getSubjectNames(): string { + return this.getSubjects().map(subject => subject.name).join(", "); + } } export const clusterRoleBindingApi = new KubeApi({ diff --git a/src/renderer/api/endpoints/cluster-role.api.ts b/src/renderer/api/endpoints/cluster-role.api.ts index 6471b9c5e1..55a2f5283d 100644 --- a/src/renderer/api/endpoints/cluster-role.api.ts +++ b/src/renderer/api/endpoints/cluster-role.api.ts @@ -19,13 +19,26 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Role } from "./role.api"; import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; -export class ClusterRole extends Role { +export interface ClusterRole { + rules: { + verbs: string[]; + apiGroups: string[]; + resources: string[]; + resourceNames?: string[]; + }[]; +} + +export class ClusterRole extends KubeObject { static kind = "ClusterRole"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"; + + getRules() { + return this.rules || []; + } } export const clusterRoleApi = new KubeApi({ diff --git a/src/renderer/api/endpoints/role-binding.api.ts b/src/renderer/api/endpoints/role-binding.api.ts index 4e53752df1..c8c83d3151 100644 --- a/src/renderer/api/endpoints/role-binding.api.ts +++ b/src/renderer/api/endpoints/role-binding.api.ts @@ -24,15 +24,17 @@ import { KubeObject } from "../kube-object"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -export interface IRoleBindingSubject { - kind: string; +export type RoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface RoleBindingSubject { + kind: RoleBindingSubjectKind; name: string; namespace?: string; apiGroup?: string; } export interface RoleBinding { - subjects?: IRoleBindingSubject[]; + subjects?: RoleBindingSubject[]; roleRef: { kind: string; name: string; diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index 77c9df5d47..d4f75d7434 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -78,32 +78,31 @@ export class CatalogEntityDrawerMenu extends React.Comp return []; } - const menuItems = this.contextMenu.menuItems.filter((menuItem) => { - return menuItem.icon && !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source; - }); + const items: React.ReactChild[] = []; - const items = menuItems.map((menuItem, index) => { - const props = menuItem.icon.includes(" this.onMenuItemClick(menuItem)}> + const key = menuItem.icon.includes(" this.onMenuItemClick(menuItem)}> ); + } - }); - - items.unshift( + items.push( this.addToHotbar(entity) }> ); - items.reverse(); - return items; } diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 2414d90a49..b01ad3010a 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -171,12 +171,10 @@ export class Catalog extends React.Component { } renderItemMenu = (item: CatalogEntityItem) => { - const menuItems = this.contextMenu.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === item.entity.metadata.source); - return ( item.onContextMenuOpen(this.contextMenu)}> { - menuItems.map((menuItem, index) => ( + this.contextMenu.menuItems.map((menuItem, index) => ( this.onMenuItemClick(menuItem)}> {menuItem.title} diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index e092949ab7..3361362b4b 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -76,7 +76,8 @@ describe("Extensions", () => { absolutePath: "/absolute/path", manifestPath: "/symlinked/path/package.json", isBundled: false, - isEnabled: true + isEnabled: true, + isCompatible: true }); }); diff --git a/src/renderer/components/+extensions/installed-extensions.module.css b/src/renderer/components/+extensions/installed-extensions.module.css index 831ece9024..b685f029fe 100644 --- a/src/renderer/components/+extensions/installed-extensions.module.css +++ b/src/renderer/components/+extensions/installed-extensions.module.css @@ -11,6 +11,10 @@ color: var(--colorOk); } +.invalid { + color: var(--colorWarning); +} + .title { margin-bottom: 0!important; } @@ -22,4 +26,4 @@ .frozenRow { @apply opacity-30 pointer-events-none; -} \ No newline at end of file +} diff --git a/src/renderer/components/+extensions/installed-extensions.tsx b/src/renderer/components/+extensions/installed-extensions.tsx index f896f410fd..0da09e5ec9 100644 --- a/src/renderer/components/+extensions/installed-extensions.tsx +++ b/src/renderer/components/+extensions/installed-extensions.tsx @@ -39,14 +39,18 @@ interface Props { uninstall: (extension: InstalledExtension) => void; } -function getStatus(isEnabled: boolean) { - return isEnabled ? "Enabled" : "Disabled"; +function getStatus(extension: InstalledExtension) { + if (!extension.isCompatible) { + return "Incompatible"; + } + + return extension.isEnabled ? "Enabled" : "Disabled"; } export const InstalledExtensions = observer(({ extensions, uninstall, enable, disable }: Props) => { const filters = [ (extension: InstalledExtension) => extension.manifest.name, - (extension: InstalledExtension) => getStatus(extension.isEnabled), + (extension: InstalledExtension) => getStatus(extension), (extension: InstalledExtension) => extension.manifest.version, ]; @@ -87,7 +91,7 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di const data = useMemo( () => { return extensions.map(extension => { - const { id, isEnabled, manifest } = extension; + const { id, isEnabled, isCompatible, manifest } = extension; const { name, description, version } = manifest; const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); @@ -102,29 +106,34 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di ), version, status: ( -
- {getStatus(isEnabled)} +
+ {getStatus(extension)}
), actions: ( - {isEnabled ? ( - disable(id)} - > - - Disable - - ) : ( - enable(id)} - > - - Enable - + { isCompatible && ( + <> + {isEnabled ? ( + disable(id)} + > + + Disable + + ) : ( + enable(id)} + > + + Enable + + )} + )} + uninstall(extension)} diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index c35ae72513..c1fe06c258 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -32,14 +32,12 @@ import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { showIcons?: boolean; - showClusterOption?: boolean; // show "Cluster" option on the top (default: false) showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) customizeOptions?(options: SelectOption[]): SelectOption[]; } const defaultProps: Partial = { showIcons: true, - showClusterOption: false, }; @observer @@ -61,13 +59,11 @@ export class NamespaceSelect extends React.Component { } @computed.struct get options(): SelectOption[] { - const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; + const { customizeOptions, showAllNamespacesOption } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); if (showAllNamespacesOption) { options.unshift({ label: "All Namespaces", value: "" }); - } else if (showClusterOption) { - options.unshift({ label: "Cluster", value: "" }); } if (customizeOptions) { diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 29098c8a49..f865787d76 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -20,7 +20,7 @@ */ import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, reaction, } from "mobx"; -import { autoBind, createStorage } from "../../utils"; +import { autoBind, createStorage, noop } from "../../utils"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { apiManager } from "../../api/api-manager"; @@ -97,13 +97,16 @@ export class NamespaceStore extends KubeObjectStore { return this.selectedNamespaces; } - getSubscribeApis() { - // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted + subscribe() { + /** + * if user has given static list of namespaces let's not start watches + * because watch adds stuff that's not wanted or will just fail + */ if (this.context?.cluster.accessibleNamespaces.length > 0) { - return []; + return noop; } - return super.getSubscribeApis(); + return super.subscribe(); } protected async loadItems(params: KubeObjectStoreLoadingParams) { diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx deleted file mode 100644 index 273a3c0d51..0000000000 --- a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import "./add-role-binding-dialog.scss"; - -import React from "react"; -import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Select, SelectOption } from "../select"; -import { SubTitle } from "../layout/sub-title"; -import type { IRoleBindingSubject, Role, RoleBinding, ServiceAccount } from "../../api/endpoints"; -import { Icon } from "../icon"; -import { Input } from "../input"; -import { NamespaceSelect } from "../+namespaces/namespace-select"; -import { Checkbox } from "../checkbox"; -import { KubeObject } from "../../api/kube-object"; -import { Notifications } from "../notifications"; -import { rolesStore } from "../+user-management-roles/roles.store"; -import { namespaceStore } from "../+namespaces/namespace.store"; -import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store"; -import { roleBindingsStore } from "./role-bindings.store"; -import { showDetails } from "../kube-object"; -import type { KubeObjectStore } from "../../kube-object.store"; - -interface BindingSelectOption extends SelectOption { - value: string; // binding name - item?: ServiceAccount | any; - subject?: IRoleBindingSubject; // used for new user/group when users-management-api not available -} - -interface Props extends Partial { -} - -const dialogState = observable.object({ - isOpen: false, - data: null as RoleBinding, -}); - -@observer -export class AddRoleBindingDialog extends React.Component { - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(roleBinding?: RoleBinding) { - dialogState.isOpen = true; - dialogState.data = roleBinding; - } - - static close() { - dialogState.isOpen = false; - } - - get roleBinding(): RoleBinding { - return dialogState.data; - } - - @observable isLoading = false; - @observable selectedRoleId = ""; - @observable useRoleForBindingName = true; - @observable bindingName = ""; // new role-binding name - @observable bindContext = ""; // empty value means "cluster-wide", otherwise bind to namespace - @observable selectedAccounts = observable.array([], { deep: false }); - - @computed get isEditing() { - return !!this.roleBinding; - } - - @computed get selectedRole() { - return rolesStore.items.find(role => role.getId() === this.selectedRoleId); - } - - @computed get selectedBindings() { - return [ - ...this.selectedAccounts, - ]; - } - - close = () => { - AddRoleBindingDialog.close(); - }; - - async loadData() { - const stores: KubeObjectStore[] = [ - namespaceStore, - rolesStore, - serviceAccountsStore, - ]; - - this.isLoading = true; - await Promise.all(stores.map(store => store.reloadAll())); - this.isLoading = false; - } - - onOpen = async () => { - await this.loadData(); - - if (this.roleBinding) { - const { name, kind } = this.roleBinding.roleRef; - const role = rolesStore.items.find(role => role.kind === kind && role.getName() === name); - - if (role) { - this.selectedRoleId = role.getId(); - this.bindContext = role.getNs() || ""; - } - } - }; - - reset = () => { - this.selectedRoleId = ""; - this.bindContext = ""; - this.selectedAccounts.clear(); - }; - - onBindContextChange = (namespace: string) => { - this.bindContext = namespace; - const roleContext = this.selectedRole && this.selectedRole.getNs() || ""; - - if (this.bindContext && this.bindContext !== roleContext) { - this.selectedRoleId = ""; // reset previously selected role for specific context - } - }; - - createBindings = async () => { - const { selectedRole, bindContext: namespace, selectedBindings, bindingName, useRoleForBindingName } = this; - - const subjects = selectedBindings.map((item: KubeObject | IRoleBindingSubject) => { - if (item instanceof KubeObject) { - return { - name: item.getName(), - kind: item.kind, - namespace: item.getNs(), - }; - } - - return item; - }); - - try { - let roleBinding: RoleBinding; - - if (this.isEditing) { - roleBinding = await roleBindingsStore.updateSubjects({ - roleBinding: this.roleBinding, - addSubjects: subjects, - }); - } else { - const name = useRoleForBindingName ? selectedRole.getName() : bindingName; - - roleBinding = await roleBindingsStore.create({ name, namespace }, { - subjects, - roleRef: { - name: selectedRole.getName(), - kind: selectedRole.kind, - } - }); - } - showDetails(roleBinding.selfLink); - this.close(); - } catch (err) { - Notifications.error(err); - } - }; - - @computed get roleOptions(): BindingSelectOption[] { - let roles = rolesStore.items as Role[]; - - if (this.bindContext) { - // show only cluster-roles or roles for selected context namespace - roles = roles.filter(role => !role.getNs() || role.getNs() === this.bindContext); - } - - return roles.map(role => { - const name = role.getName(); - const namespace = role.getNs(); - - return { - value: role.getId(), - label: name + (namespace ? ` (${namespace})` : "") - }; - }); - } - - @computed get serviceAccountOptions(): BindingSelectOption[] { - return serviceAccountsStore.items.map(account => { - const name = account.getName(); - const namespace = account.getNs(); - - return { - item: account, - value: name, - label: <> {name} ({namespace}) - }; - }); - } - - renderContents() { - const unwrapBindings = (options: BindingSelectOption[]) => options.map(option => option.item || option.subject); - - return ( - <> - - this.onBindContextChange(value)} - /> - - - this.bindingName = v} - /> - ) - } - - ) - } - - - ) => { + if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) { + this.bindingName = value.getName(); + } + + this.selectedRoleRef = value; + }} + /> + + + this.bindingName = val} + /> + + + + Users + this.selectedUsers.add(newUser)} + items={Array.from(this.selectedUsers)} + remove={({ oldItem }) => this.selectedUsers.delete(oldItem)} + /> + + Groups + this.selectedGroups.add(newGroup)} + items={Array.from(this.selectedGroups)} + remove={({ oldItem }) => this.selectedGroups.delete(oldItem)} + /> + + Service Accounts + this.clusterRoleName = v} + /> + + + + ); + } +} diff --git a/src/renderer/components/+user-management-roles/role-details.scss b/src/renderer/components/+user-management/+cluster-roles/details.scss similarity index 100% rename from src/renderer/components/+user-management-roles/role-details.scss rename to src/renderer/components/+user-management/+cluster-roles/details.scss diff --git a/src/renderer/components/+user-management/+cluster-roles/details.tsx b/src/renderer/components/+user-management/+cluster-roles/details.tsx new file mode 100644 index 0000000000..52bee8dc8e --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/details.tsx @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import "./details.scss"; + +import { observer } from "mobx-react"; +import React from "react"; + +import { KubeEventDetails } from "../../+events/kube-event-details"; +import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry"; +import { DrawerTitle } from "../../drawer"; +import type { KubeObjectDetailsProps } from "../../kube-object"; +import { KubeObjectMeta } from "../../kube-object/kube-object-meta"; +import type { ClusterRole } from "../../../api/endpoints"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class ClusterRoleDetails extends React.Component { + render() { + const { object: clusterRole } = this.props; + + if (!clusterRole) return null; + const rules = clusterRole.getRules(); + + return ( +
+ + + + {rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => { + return ( +
+ {resources && ( + <> +
Resources
+
{resources.join(", ")}
+ + )} + {verbs && ( + <> +
Verbs
+
{verbs.join(", ")}
+ + )} + {apiGroups && ( + <> +
Api Groups
+
+ {apiGroups + .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) + .join(", ") + } +
+ + )} + {resourceNames && ( + <> +
Resource Names
+
{resourceNames.join(", ")}
+ + )} +
+ ); + })} +
+ ); + } +} + +kubeObjectDetailRegistry.add({ + kind: "ClusterRole", + apiVersions: ["rbac.authorization.k8s.io/v1"], + components: { + Details: (props) => + } +}); +kubeObjectDetailRegistry.add({ + kind: "ClusterRole", + apiVersions: ["rbac.authorization.k8s.io/v1"], + priority: 5, + components: { + Details: (props) => + } +}); diff --git a/src/renderer/components/+user-management-roles-bindings/index.ts b/src/renderer/components/+user-management/+cluster-roles/index.ts similarity index 90% rename from src/renderer/components/+user-management-roles-bindings/index.ts rename to src/renderer/components/+user-management/+cluster-roles/index.ts index ca08a30691..e45d58073c 100644 --- a/src/renderer/components/+user-management-roles-bindings/index.ts +++ b/src/renderer/components/+user-management/+cluster-roles/index.ts @@ -18,7 +18,6 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -export * from "./role-bindings"; -export * from "./role-binding-details"; -export * from "./add-role-binding-dialog"; +export * from "./view"; +export * from "./details"; +export * from "./add-dialog"; diff --git a/src/renderer/components/+user-management/+cluster-roles/store.ts b/src/renderer/components/+user-management/+cluster-roles/store.ts new file mode 100644 index 0000000000..6fe25a2c42 --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/store.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { apiManager } from "../../../api/api-manager"; +import { ClusterRole, clusterRoleApi } from "../../../api/endpoints"; +import { KubeObjectStore } from "../../../kube-object.store"; +import { autoBind } from "../../../utils"; + +export class ClusterRolesStore extends KubeObjectStore { + api = clusterRoleApi; + + constructor() { + super(); + autoBind(this); + } + + protected sortItems(items: ClusterRole[]) { + return super.sortItems(items, [ + clusterRole => clusterRole.kind, + clusterRole => clusterRole.getName(), + ]); + } +} + +export const clusterRolesStore = new ClusterRolesStore(); + +apiManager.registerStore(clusterRolesStore); diff --git a/src/renderer/components/+user-management/+cluster-roles/view.scss b/src/renderer/components/+user-management/+cluster-roles/view.scss new file mode 100644 index 0000000000..2225c17a06 --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/view.scss @@ -0,0 +1,11 @@ +.ClusterRoles { + .help-icon { + margin-left: $margin / 2; + } + + .TableCell { + &.warning { + @include table-cell-warning; + } + } +} diff --git a/src/renderer/components/+user-management/+cluster-roles/view.tsx b/src/renderer/components/+user-management/+cluster-roles/view.tsx new file mode 100644 index 0000000000..43d8dedcbb --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/view.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import "./view.scss"; + +import { observer } from "mobx-react"; +import React from "react"; +import type { RouteComponentProps } from "react-router"; +import type { ClusterRole } from "../../../api/endpoints"; +import { KubeObjectListLayout } from "../../kube-object"; +import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; +import type { ClusterRolesRouteParams } from "../user-management.route"; +import { AddClusterRoleDialog } from "./add-dialog"; +import { clusterRolesStore } from "./store"; + +enum columnId { + name = "name", + namespace = "namespace", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class ClusterRoles extends React.Component { + render() { + return ( + <> + clusterRole.getName(), + [columnId.age]: (clusterRole: ClusterRole) => clusterRole.getTimeDiffFromNow(), + }} + searchFilters={[ + (clusterRole: ClusterRole) => clusterRole.getSearchFields(), + ]} + renderHeaderTitle="Cluster Roles" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={(clusterRole: ClusterRole) => [ + clusterRole.getName(), + , + clusterRole.getAge(), + ]} + addRemoveButtons={{ + onAdd: () => AddClusterRoleDialog.open(), + addTooltip: "Create new ClusterRole", + }} + /> + + + ); + } +} diff --git a/src/renderer/components/+user-management/+role-bindings/details.scss b/src/renderer/components/+user-management/+role-bindings/details.scss new file mode 100644 index 0000000000..805e5e05fe --- /dev/null +++ b/src/renderer/components/+user-management/+role-bindings/details.scss @@ -0,0 +1,2 @@ +.RoleBindingDetails { +} \ No newline at end of file diff --git a/src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx b/src/renderer/components/+user-management/+role-bindings/details.tsx similarity index 59% rename from src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx rename to src/renderer/components/+user-management/+role-bindings/details.tsx index 3fb14c4bc4..39080d56b5 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx +++ b/src/renderer/components/+user-management/+role-bindings/details.tsx @@ -19,35 +19,32 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./role-binding-details.scss"; +import "./details.scss"; -import React from "react"; -import { AddRemoveButtons } from "../add-remove-buttons"; -import type { IRoleBindingSubject, RoleBinding } from "../../api/endpoints"; -import { boundMethod, prevDefault } from "../../utils"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { ConfirmDialog } from "../confirm-dialog"; -import { DrawerTitle } from "../drawer"; -import { KubeEventDetails } from "../+events/kube-event-details"; +import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { observable, reaction, makeObservable } from "mobx"; -import { roleBindingsStore } from "./role-bindings.store"; -import { AddRoleBindingDialog } from "./add-role-binding-dialog"; -import type { KubeObjectDetailsProps } from "../kube-object"; -import { KubeObjectMeta } from "../kube-object/kube-object-meta"; -import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import React from "react"; +import { KubeEventDetails } from "../../+events/kube-event-details"; +import type { RoleBinding, RoleBindingSubject } from "../../../api/endpoints"; +import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry"; +import { prevDefault, boundMethod } from "../../../utils"; +import { AddRemoveButtons } from "../../add-remove-buttons"; +import { ConfirmDialog } from "../../confirm-dialog"; +import { DrawerTitle } from "../../drawer"; +import type { KubeObjectDetailsProps } from "../../kube-object"; +import { KubeObjectMeta } from "../../kube-object/kube-object-meta"; +import { Table, TableCell, TableHead, TableRow } from "../../table"; +import { RoleBindingDialog } from "./dialog"; +import { roleBindingsStore } from "./store"; +import { ObservableHashSet } from "../../../../common/utils/hash-set"; +import { hashRoleBindingSubject } from "./hashers"; interface Props extends KubeObjectDetailsProps { } @observer export class RoleBindingDetails extends React.Component { - @observable selectedSubjects = observable.array([], { deep: false }); - - constructor(props: Props) { - super(props); - makeObservable(this); - } + selectedSubjects = new ObservableHashSet([], hashRoleBindingSubject); async componentDidMount() { disposeOnUnmount(this, [ @@ -57,24 +54,13 @@ export class RoleBindingDetails extends React.Component { ]); } - selectSubject(subject: IRoleBindingSubject) { - const { selectedSubjects } = this; - const isSelected = selectedSubjects.includes(subject); - - selectedSubjects.replace( - isSelected - ? selectedSubjects.filter(sub => sub !== subject) // unselect - : selectedSubjects.concat(subject) // select - ); - } - @boundMethod removeSelectedSubjects() { const { object: roleBinding } = this.props; const { selectedSubjects } = this; ConfirmDialog.open({ - ok: () => roleBindingsStore.updateSubjects({ roleBinding, removeSubjects: selectedSubjects }), + ok: () => roleBindingsStore.removeSubjects(roleBinding, selectedSubjects.toJSON()), labelOk: `Remove`, message: (

Remove selected bindings for {roleBinding.getName()}?

@@ -94,9 +80,9 @@ export class RoleBindingDetails extends React.Component { return (
- + - + Kind @@ -110,26 +96,27 @@ export class RoleBindingDetails extends React.Component {
- + {subjects.length > 0 && ( - - Binding + + Name Type Namespace { subjects.map((subject, i) => { const { kind, name, namespace } = subject; - const isSelected = selectedSubjects.includes(subject); + const isSelected = selectedSubjects.has(subject); return ( this.selectSubject(subject))} + key={i} + selected={isSelected} + onClick={prevDefault(() => this.selectedSubjects.toggle(subject))} > - + {name} {kind} {namespace || "-"} @@ -141,9 +128,9 @@ export class RoleBindingDetails extends React.Component { )} AddRoleBindingDialog.open(roleBinding)} - onRemove={selectedSubjects.length ? this.removeSelectedSubjects : null} - addTooltip={`Add bindings to ${roleRef.name}`} + onAdd={() => RoleBindingDialog.open(roleBinding)} + onRemove={selectedSubjects.size ? this.removeSelectedSubjects : null} + addTooltip={`Edit bindings of ${roleRef.name}`} removeTooltip={`Remove selected bindings from ${roleRef.name}`} /> @@ -166,20 +153,3 @@ kubeObjectDetailRegistry.add({ Details: (props) => } }); - - -kubeObjectDetailRegistry.add({ - kind: "ClusterRoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - components: { - Details: (props) => - } -}); -kubeObjectDetailRegistry.add({ - kind: "ClusterRoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - priority: 5, - components: { - Details: (props) => - } -}); diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.scss b/src/renderer/components/+user-management/+role-bindings/dialog.scss similarity index 100% rename from src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.scss rename to src/renderer/components/+user-management/+role-bindings/dialog.scss diff --git a/src/renderer/components/+user-management/+role-bindings/dialog.tsx b/src/renderer/components/+user-management/+role-bindings/dialog.tsx new file mode 100644 index 0000000000..0bed732381 --- /dev/null +++ b/src/renderer/components/+user-management/+role-bindings/dialog.tsx @@ -0,0 +1,303 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import "./dialog.scss"; + +import { computed, observable, makeObservable, action } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; + +import { rolesStore } from "../+roles/store"; +import { serviceAccountsStore } from "../+service-accounts/store"; +import { NamespaceSelect } from "../../+namespaces/namespace-select"; +import { ClusterRole, Role, roleApi, RoleBinding, RoleBindingSubject, ServiceAccount } from "../../../api/endpoints"; +import { Dialog, DialogProps } from "../../dialog"; +import { EditableList } from "../../editable-list"; +import { Icon } from "../../icon"; +import { showDetails } from "../../kube-object"; +import { SubTitle } from "../../layout/sub-title"; +import { Notifications } from "../../notifications"; +import { Select, SelectOption } from "../../select"; +import { Wizard, WizardStep } from "../../wizard"; +import { roleBindingsStore } from "./store"; +import { clusterRolesStore } from "../+cluster-roles/store"; +import { Input } from "../../input"; +import { getRoleRefSelectOption, ServiceAccountOption } from "../select-options"; +import { ObservableHashSet, nFircate } from "../../../utils"; + +interface Props extends Partial { +} + +interface DialogState { + isOpen: boolean; + data?: RoleBinding; +} + +@observer +export class RoleBindingDialog extends React.Component { + static state = observable.object({ + isOpen: false, + }); + + constructor(props: Props) { + super(props); + makeObservable(this); + } + + static open(roleBinding?: RoleBinding) { + RoleBindingDialog.state.isOpen = true; + RoleBindingDialog.state.data = roleBinding; + } + + static close() { + RoleBindingDialog.state.isOpen = false; + RoleBindingDialog.state.data = undefined; + } + + get roleBinding(): RoleBinding { + return RoleBindingDialog.state.data; + } + + @computed get isEditing() { + return !!this.roleBinding; + } + + @observable.ref selectedRoleRef: Role | ClusterRole | undefined = undefined; + @observable bindingName = ""; + @observable bindingNamespace = ""; + selectedAccounts = new ObservableHashSet([], sa => sa.metadata.uid); + selectedUsers = observable.set([]); + selectedGroups = observable.set([]); + + @computed get selectedBindings(): RoleBindingSubject[] { + const serviceAccounts = Array.from(this.selectedAccounts, sa => ({ + name: sa.getName(), + kind: "ServiceAccount" as const, + namespace: this.bindingNamespace, + })); + const users = Array.from(this.selectedUsers, user => ({ + name: user, + kind: "User" as const, + namespace: this.bindingNamespace, + })); + const groups = Array.from(this.selectedGroups, group => ({ + name: group, + kind: "Group" as const, + namespace: this.bindingNamespace, + })); + + return [ + ...serviceAccounts, + ...users, + ...groups, + ]; + } + + @computed get roleRefOptions(): SelectOption[] { + const roles = rolesStore.items + .filter(role => role.getNs() === this.bindingNamespace) + .map(getRoleRefSelectOption); + const clusterRoles = clusterRolesStore.items + .map(getRoleRefSelectOption); + + return [ + ...roles, + ...clusterRoles, + ]; + } + + @computed get serviceAccountOptions(): ServiceAccountOption[] { + return serviceAccountsStore.items.map(account => { + const name = account.getName(); + const namespace = account.getNs(); + + return { + value: `${account.getName()}%${account.getNs()}`, + account, + label: <> {name} ({namespace}) + }; + }); + } + + @computed get selectedServiceAccountOptions(): ServiceAccountOption[] { + return this.serviceAccountOptions.filter(({ account }) => this.selectedAccounts.has(account)); + } + + @action + onOpen = () => { + const binding = this.roleBinding; + + if (!binding) { + return this.reset(); + } + + this.selectedRoleRef = (binding.roleRef.kind === roleApi.kind ? rolesStore : clusterRolesStore) + .items + .find(item => item.getName() === binding.roleRef.name); + + this.bindingName = binding.getName(); + this.bindingNamespace = binding.getNs(); + + const [saSubjects, uSubjects, gSubjects] = nFircate(binding.getSubjects(), "kind", ["ServiceAccount", "User", "Group"]); + const accountNames = new Set(saSubjects.map(acc => acc.name)); + + this.selectedAccounts.replace( + serviceAccountsStore.items + .filter(sa => accountNames.has(sa.getName())) + ); + this.selectedUsers.replace(uSubjects.map(user => user.name)); + this.selectedGroups.replace(gSubjects.map(group => group.name)); + }; + + @action + reset = () => { + this.selectedRoleRef = undefined; + this.bindingName = ""; + this.bindingNamespace = ""; + this.selectedAccounts.clear(); + this.selectedUsers.clear(); + this.selectedGroups.clear(); + }; + + createBindings = async () => { + const { selectedRoleRef, bindingNamespace: namespace, selectedBindings } = this; + + try { + const roleBinding = this.isEditing + ? await roleBindingsStore.updateSubjects(this.roleBinding, selectedBindings) + : await roleBindingsStore.create({ name: this.bindingName, namespace }, { + subjects: selectedBindings, + roleRef: { + name: selectedRoleRef.getName(), + kind: selectedRoleRef.kind, + } + }); + + showDetails(roleBinding.selfLink); + RoleBindingDialog.close(); + } catch (err) { + Notifications.error(err); + } + }; + + renderContents() { + return ( + <> + + this.bindingNamespace = value} + /> + + + this.bindingName = value} + /> + + + + Users + this.selectedUsers.add(newUser)} + items={Array.from(this.selectedUsers)} + remove={({ oldItem }) => this.selectedUsers.delete(oldItem)} + /> + + Groups + this.selectedGroups.add(newGroup)} + items={Array.from(this.selectedGroups)} + remove={({ oldItem }) => this.selectedGroups.delete(oldItem)} + /> + + Service Accounts + { } diff --git a/src/renderer/components/+user-management/+service-accounts/index.ts b/src/renderer/components/+user-management/+service-accounts/index.ts new file mode 100644 index 0000000000..b02c71be28 --- /dev/null +++ b/src/renderer/components/+user-management/+service-accounts/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +export * from "./view"; +export * from "./details"; +export * from "./create-dialog"; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts-secret.scss b/src/renderer/components/+user-management/+service-accounts/secret.scss similarity index 100% rename from src/renderer/components/+user-management-service-accounts/service-accounts-secret.scss rename to src/renderer/components/+user-management/+service-accounts/secret.scss diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts-secret.tsx b/src/renderer/components/+user-management/+service-accounts/secret.tsx similarity index 94% rename from src/renderer/components/+user-management-service-accounts/service-accounts-secret.tsx rename to src/renderer/components/+user-management/+service-accounts/secret.tsx index d57bb8cc05..4eff2b30dd 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts-secret.tsx +++ b/src/renderer/components/+user-management/+service-accounts/secret.tsx @@ -19,13 +19,14 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./service-accounts-secret.scss"; +import "./secret.scss"; -import React from "react"; import moment from "moment"; -import { Icon } from "../icon"; -import type { Secret } from "../../api/endpoints/secret.api"; -import { prevDefault } from "../../utils"; +import React from "react"; + +import type { Secret } from "../../../api/endpoints/secret.api"; +import { prevDefault } from "../../../utils"; +import { Icon } from "../../icon"; interface Props { secret: Secret; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.store.ts b/src/renderer/components/+user-management/+service-accounts/store.ts similarity index 87% rename from src/renderer/components/+user-management-service-accounts/service-accounts.store.ts rename to src/renderer/components/+user-management/+service-accounts/store.ts index c0917dab12..51bbabca34 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.store.ts +++ b/src/renderer/components/+user-management/+service-accounts/store.ts @@ -19,10 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { autoBind } from "../../utils"; -import { ServiceAccount, serviceAccountsApi } from "../../api/endpoints"; -import { KubeObjectStore } from "../../kube-object.store"; -import { apiManager } from "../../api/api-manager"; +import { apiManager } from "../../../api/api-manager"; +import { ServiceAccount, serviceAccountsApi } from "../../../api/endpoints"; +import { KubeObjectStore } from "../../../kube-object.store"; +import { autoBind } from "../../../utils"; export class ServiceAccountsStore extends KubeObjectStore { api = serviceAccountsApi; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.scss b/src/renderer/components/+user-management/+service-accounts/view.scss similarity index 100% rename from src/renderer/components/+user-management-service-accounts/service-accounts.scss rename to src/renderer/components/+user-management/+service-accounts/view.scss diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx b/src/renderer/components/+user-management/+service-accounts/view.tsx similarity index 80% rename from src/renderer/components/+user-management-service-accounts/service-accounts.tsx rename to src/renderer/components/+user-management/+service-accounts/view.tsx index dff4d1fe58..442fbe3d77 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx +++ b/src/renderer/components/+user-management/+service-accounts/view.tsx @@ -19,22 +19,22 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./service-accounts.scss"; +import "./view.scss"; -import React from "react"; import { observer } from "mobx-react"; -import type { ServiceAccount } from "../../api/endpoints/service-accounts.api"; +import React from "react"; import type { RouteComponentProps } from "react-router"; -import type { KubeObjectMenuProps } from "../kube-object/kube-object-menu"; -import { MenuItem } from "../menu"; -import { openServiceAccountKubeConfig } from "../kubeconfig-dialog"; -import { Icon } from "../icon"; -import { KubeObjectListLayout } from "../kube-object"; -import type { IServiceAccountsRouteParams } from "../+user-management"; -import { serviceAccountsStore } from "./service-accounts.store"; -import { CreateServiceAccountDialog } from "./create-service-account-dialog"; -import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { ServiceAccountsRouteParams } from "../user-management.route"; +import { kubeObjectMenuRegistry } from "../../../../extensions/registries/kube-object-menu-registry"; +import type { ServiceAccount } from "../../../api/endpoints/service-accounts.api"; +import { Icon } from "../../icon"; +import { KubeObjectListLayout } from "../../kube-object"; +import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; +import type { KubeObjectMenuProps } from "../../kube-object/kube-object-menu"; +import { openServiceAccountKubeConfig } from "../../kubeconfig-dialog"; +import { MenuItem } from "../../menu"; +import { CreateServiceAccountDialog } from "./create-dialog"; +import { serviceAccountsStore } from "./store"; enum columnId { name = "name", @@ -42,7 +42,7 @@ enum columnId { age = "age", } -interface Props extends RouteComponentProps { +interface Props extends RouteComponentProps { } @observer diff --git a/src/renderer/components/+user-management/select-options.tsx b/src/renderer/components/+user-management/select-options.tsx new file mode 100644 index 0000000000..65bfeeec9e --- /dev/null +++ b/src/renderer/components/+user-management/select-options.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import type { ServiceAccount } from "../../api/endpoints"; +import type { KubeObject } from "../../api/kube-object"; +import { Icon } from "../icon"; +import type { SelectOption } from "../select"; +import { TooltipPosition } from "../tooltip"; + +export type ServiceAccountOption = SelectOption & { account: ServiceAccount }; + +export function getRoleRefSelectOption(item: T): SelectOption { + return { + value: item, + label: ( + <> + + {" "} + {item.getName()} + + ), + }; +} diff --git a/src/renderer/components/+user-management/user-management.route.ts b/src/renderer/components/+user-management/user-management.route.ts index f6e4def1e8..290e7fa2d4 100644 --- a/src/renderer/components/+user-management/user-management.route.ts +++ b/src/renderer/components/+user-management/user-management.route.ts @@ -19,45 +19,62 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; +import type { RouteProps } from "react-router"; + // Routes export const serviceAccountsRoute: RouteProps = { path: "/service-accounts" }; +export const podSecurityPoliciesRoute: RouteProps = { + path: "/pod-security-policies" +}; export const rolesRoute: RouteProps = { path: "/roles" }; +export const clusterRolesRoute: RouteProps = { + path: "/cluster-roles" +}; export const roleBindingsRoute: RouteProps = { path: "/role-bindings" }; -export const podSecurityPoliciesRoute: RouteProps = { - path: "/pod-security-policies" +export const clusterRoleBindingsRoute: RouteProps = { + path: "/cluster-role-bindings" }; export const usersManagementRoute: RouteProps = { path: [ serviceAccountsRoute, + podSecurityPoliciesRoute, roleBindingsRoute, + clusterRoleBindingsRoute, rolesRoute, - podSecurityPoliciesRoute + clusterRolesRoute, ].map(route => route.path.toString()) }; // Route params -export interface IServiceAccountsRouteParams { +export interface ServiceAccountsRouteParams { } -export interface IRoleBindingsRouteParams { +export interface RoleBindingsRouteParams { } -export interface IRolesRouteParams { +export interface ClusterRoleBindingsRouteParams { +} + +export interface RolesRouteParams { +} + +export interface ClusterRolesRouteParams { } // URL-builders export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(params); -export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); -export const roleBindingsURL = buildURL(roleBindingsRoute.path); -export const rolesURL = buildURL(rolesRoute.path); +export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); +export const roleBindingsURL = buildURL(roleBindingsRoute.path); +export const clusterRoleBindingsURL = buildURL(clusterRoleBindingsRoute.path); +export const rolesURL = buildURL(rolesRoute.path); +export const clusterRolesURL = buildURL(clusterRolesRoute.path); diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index cb5c5837f3..c714f34525 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -20,15 +20,32 @@ */ import "./user-management.scss"; -import React from "react"; + import { observer } from "mobx-react"; -import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; -import { Roles } from "../+user-management-roles"; -import { RoleBindings } from "../+user-management-roles-bindings"; -import { ServiceAccounts } from "../+user-management-service-accounts"; -import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; +import React from "react"; + import { PodSecurityPolicies } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import { ClusterRoles } from "./+cluster-roles"; +import { ClusterRoleBindings } from "./+cluster-role-bindings"; +import { Roles } from "./+roles"; +import { RoleBindings } from "./+role-bindings"; +import { ServiceAccounts } from "./+service-accounts"; +import { + clusterRoleBindingsRoute, + clusterRoleBindingsURL, + clusterRolesRoute, + clusterRolesURL, + podSecurityPoliciesRoute, + podSecurityPoliciesURL, + roleBindingsRoute, + roleBindingsURL, + rolesRoute, + rolesURL, + serviceAccountsRoute, + serviceAccountsURL, +} from "./user-management.route"; @observer export class UserManagement extends React.Component { @@ -44,18 +61,16 @@ export class UserManagement extends React.Component { }); } - if (isAllowedResource("rolebindings") || isAllowedResource("clusterrolebindings")) { - // TODO: seperate out these two pages + if (isAllowedResource("clusterroles")) { tabRoutes.push({ - title: "Role Bindings", - component: RoleBindings, - url: roleBindingsURL(), - routePath: roleBindingsRoute.path.toString(), + title: "Cluster Roles", + component: ClusterRoles, + url: clusterRolesURL(), + routePath: clusterRolesRoute.path.toString(), }); } - if (isAllowedResource("roles") || isAllowedResource("clusterroles")) { - // TODO: seperate out these two pages + if (isAllowedResource("roles")) { tabRoutes.push({ title: "Roles", component: Roles, @@ -64,6 +79,24 @@ export class UserManagement extends React.Component { }); } + if (isAllowedResource("clusterrolebindings")) { + tabRoutes.push({ + title: "Cluster Role Bindings", + component: ClusterRoleBindings, + url: clusterRoleBindingsURL(), + routePath: clusterRoleBindingsRoute.path.toString(), + }); + } + + if (isAllowedResource("rolebindings")) { + tabRoutes.push({ + title: "Role Bindings", + component: RoleBindings, + url: roleBindingsURL(), + routePath: roleBindingsRoute.path.toString(), + }); + } + if (isAllowedResource("podsecuritypolicies")) { tabRoutes.push({ title: "Pod Security Policies", diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 948fec03ce..cde061b1aa 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -41,7 +41,6 @@ import { Events } from "./+events/events"; import { eventRoute } from "./+events"; import { Apps, appsRoute } from "./+apps"; import { KubeObjectDetails } from "./kube-object/kube-object-details"; -import { AddRoleBindingDialog } from "./+user-management-roles-bindings"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; import { CustomResources } from "./+custom-resources/custom-resources"; @@ -201,7 +200,6 @@ export class App extends React.Component { - diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index 4160073882..bf3fea3016 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -23,7 +23,7 @@ import "./dock-tab.scss"; import React from "react"; import { observer } from "mobx-react"; -import { boundMethod, cssNames, prevDefault } from "../../utils"; +import { boundMethod, cssNames, prevDefault, isMiddleClick } from "../../utils"; import { dockStore, IDockTab } from "./dock.store"; import { Tab, TabProps } from "../tabs"; import { Icon } from "../icon"; @@ -88,13 +88,13 @@ export class DockTab extends React.Component { const { className, moreActions, ...tabProps } = this.props; const { title, pinned } = tabProps.value; const label = ( -
+
{title} {moreActions} {!pinned && ( )} diff --git a/src/renderer/components/dock/log.store.ts b/src/renderer/components/dock/log.store.ts index 1010c018cf..91682e8c0c 100644 --- a/src/renderer/components/dock/log.store.ts +++ b/src/renderer/components/dock/log.store.ts @@ -107,7 +107,7 @@ export class LogStore { }); // Add newly received logs to bottom - this.podLogs.set(tabId, [...oldLogs, ...logs]); + this.podLogs.set(tabId, [...oldLogs, ...logs.filter(Boolean)]); } catch (error) { this.handlerError(tabId, error); } diff --git a/src/renderer/components/editable-list/editable-list.scss b/src/renderer/components/editable-list/editable-list.scss index 9bcd266da1..b30fdf7f57 100644 --- a/src/renderer/components/editable-list/editable-list.scss +++ b/src/renderer/components/editable-list/editable-list.scss @@ -23,7 +23,7 @@ .el-contents { display: flex; flex-direction: column; - margin-top: $padding * 2; + margin: $padding 0px; .el-value-remove { .Icon { @@ -35,7 +35,9 @@ display: grid; grid-template-columns: 1fr auto; padding: $padding $padding * 2; - margin-bottom: 1px; + margin-bottom: $padding / 4; + backdrop-filter: brightness(0.75); + border-radius: var(--border-radius); :last-child { margin-bottom: unset; @@ -46,4 +48,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/components/editable-list/editable-list.tsx b/src/renderer/components/editable-list/editable-list.tsx index 47bfaa8fe5..ec31a2586e 100644 --- a/src/renderer/components/editable-list/editable-list.tsx +++ b/src/renderer/components/editable-list/editable-list.tsx @@ -21,11 +21,11 @@ import "./editable-list.scss"; +import { observer } from "mobx-react"; import React from "react"; + import { Icon } from "../icon"; import { Input } from "../input"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; import { boundMethod } from "../../utils"; export interface Props { @@ -47,20 +47,14 @@ const defaultProps: Partial> = { @observer export class EditableList extends React.Component> { static defaultProps = defaultProps as Props; - @observable currentNewItem = ""; - - constructor(props: Props) { - super(props); - makeObservable(this); - } @boundMethod - onSubmit(val: string) { + onSubmit(val: string, evt: React.KeyboardEvent) { const { add } = this.props; if (val) { + evt.preventDefault(); add(val); - this.currentNewItem = ""; } } @@ -71,17 +65,15 @@ export class EditableList extends React.Component> {
this.currentNewItem = val} />
{ items.map((item, index) => ( -
+
{renderItem(item, index)}
remove(({ index, oldItem: item }))} /> diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index f346727523..f1e22d2e79 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -106,7 +106,7 @@ export class HotbarEntityIcon extends React.Component { }; const isActive = this.isActive(entity); const isPersisted = this.isPersisted(entity); - const menuItems = this.contextMenu?.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source); + const menuItems = this.contextMenu?.menuItems ?? []; if (!isPersisted) { menuItems.unshift({ diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index 224fec829c..97ad5c058a 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -48,7 +48,7 @@ --flex-gap: #{$padding / 1.5}; position: relative; - padding: $padding /4 * 3 0; + padding: $padding / 4 * 3 0; border-bottom: 1px solid $halfGray; line-height: 1; @@ -110,23 +110,17 @@ //- Themes &.theme { - &.round-black { + &.round { &.invalid.dirty { label { border-color: $colorSoftError; } } - label { - background: var(--inputControlBackground); - border: 1px solid var(--inputControlBorder); - border-radius: 5px; - padding: $padding; - color: var(--textColorTertiary); - - &:hover { - border-color: var(--inputControlHoverBorder); - } + border-radius: $radius; + border: 1px solid $halfGray; + color: inherit; + padding: $padding / 4 * 3 $padding / 4 * 3; &:focus-within { border-color: $colorInfo; @@ -136,6 +130,18 @@ display: none; } } + &.black { + label { + background: var(--inputControlBackground); + border-color: var(--inputControlBorder); + color: var(--textColorTertiary); + padding: $padding; + + &:hover { + border-color: var(--inputControlHoverBorder); + } + } + } } } } diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 1d0700b846..5de07cb856 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -41,7 +41,7 @@ type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; export type InputProps = Omit & { - theme?: "round-black"; + theme?: "round-black" | "round"; className?: string; value?: T; autoSelectOnFocus?: boolean @@ -55,7 +55,7 @@ export type InputProps = Omit): void; - onSubmit?(value: T): void; + onSubmit?(value: T, evt: React.KeyboardEvent): void; }; interface State { @@ -90,7 +90,7 @@ export class Input extends React.Component { return this.state.valid; } - setValue(value: string) { + setValue(value = "") { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; @@ -236,16 +236,15 @@ export class Input extends React.Component { } @boundMethod - onChange(evt: React.ChangeEvent) { - if (this.props.onChange) { - this.props.onChange(evt.currentTarget.value, evt); - } - + onChange(evt: React.ChangeEvent) { + this.props.onChange?.(evt.currentTarget.value, evt); this.validate(); this.autoFitHeight(); // mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing - if (!this.state.dirty) this.setState({ dirtyOnBlur: true }); + if (!this.state.dirty) { + this.setState({ dirtyOnBlur: true }); + } // re-render component when used as uncontrolled input // when used @defaultValue instead of @value changing real input.value doesn't call render() @@ -255,17 +254,19 @@ export class Input extends React.Component { } @boundMethod - onKeyDown(evt: React.KeyboardEvent) { + onKeyDown(evt: React.KeyboardEvent) { const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; - if (this.props.onKeyDown) { - this.props.onKeyDown(evt); - } + this.props.onKeyDown?.(evt); switch (evt.key) { case "Enter": if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) { - this.props.onSubmit(this.getValue()); + this.props.onSubmit(this.getValue(), evt); + + if (this.isUncontrolled) { + this.setValue(); + } } break; } @@ -303,6 +304,20 @@ export class Input extends React.Component { } } + get themeSelection(): Record { + const { theme } = this.props; + + if (!theme) { + return {}; + } + + return { + theme: true, + round: true, + black: theme === "round-black", + }; + } + @boundMethod bindRef(elem: InputElement) { this.input = elem; @@ -318,7 +333,7 @@ export class Input extends React.Component { const { focused, dirty, valid, validating, errors } = this.state; const className = cssNames("Input", this.props.className, { - [`theme ${theme}`]: theme, + ...this.themeSelection, focused, disabled, invalid: !valid, diff --git a/src/renderer/components/select/select.tsx b/src/renderer/components/select/select.tsx index 9dd5a61798..af13fecaae 100644 --- a/src/renderer/components/select/select.tsx +++ b/src/renderer/components/select/select.tsx @@ -26,10 +26,11 @@ import "./select.scss"; import React, { ReactNode } from "react"; import { computed, makeObservable } from "mobx"; import { observer } from "mobx-react"; -import { boundMethod, cssNames } from "../../utils"; import ReactSelect, { ActionMeta, components, OptionTypeBase, Props as ReactSelectProps, Styles } from "react-select"; import Creatable, { CreatableProps } from "react-select/creatable"; + import { ThemeStore } from "../../theme.store"; +import { boundMethod, cssNames } from "../../utils"; const { Menu } = components; @@ -65,8 +66,10 @@ export class Select extends React.Component { makeObservable(this); } - @computed get theme() { - return this.props.themeName || ThemeStore.getInstance().activeTheme.type; + @computed get themeClass() { + const themeName = this.props.themeName || ThemeStore.getInstance().activeTheme.type; + + return `theme-${themeName}`; } private styles: Styles = { @@ -128,7 +131,6 @@ export class Select extends React.Component { className, menuClass, isCreatable, autoConvertOptions, value, options, components = {}, ...props } = this.props; - const themeClass = `theme-${this.theme}`; const WrappedMenu = components.Menu ?? Menu; const selectProps: Partial = { @@ -138,14 +140,14 @@ export class Select extends React.Component { options: autoConvertOptions ? this.options : options, onChange: this.onChange, onKeyDown: this.onKeyDown, - className: cssNames("Select", themeClass, className), + className: cssNames("Select", this.themeClass, className), classNamePrefix: "Select", components: { ...components, Menu: props => ( ), } diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index f1b9cfba96..f869d131d4 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -22,7 +22,7 @@ import type { ClusterContext } from "./components/context"; import { action, computed, makeObservable, observable, reaction, when } from "mobx"; -import { autoBind, bifurcateArray, noop, rejectPromiseBy } from "./utils"; +import { autoBind, noop, rejectPromiseBy } from "./utils"; import { KubeObject, KubeStatus } from "./api/kube-object"; import type { IKubeWatchEvent } from "./api/kube-watch-api"; import { ItemStore } from "./item.store"; @@ -309,51 +309,31 @@ export abstract class KubeObjectStore extends ItemSt }); } - getSubscribeApis(): KubeApi[] { - return [this.api]; - } - - subscribe(apis = this.getSubscribeApis()) { + subscribe() { const abortController = new AbortController(); - const [clusterScopedApis, namespaceScopedApis] = bifurcateArray(apis, api => api.isNamespaced); - for (const api of namespaceScopedApis) { - const store = apiManager.getStore(api); - - // This waits for the context and namespaces to be ready or fails fast if the disposer is called - Promise.race([rejectPromiseBy(abortController.signal), Promise.all([store.contextReady, store.namespacesReady])]) + if (this.api.isNamespaced) { + Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) .then(() => { - if ( - store.context.cluster.isGlobalWatchEnabled - && store.loadedNamespaces.length === 0 - ) { - return store.watchNamespace(api, "", abortController); + if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { + return this.watchNamespace("", abortController); } for (const namespace of this.loadedNamespaces) { - store.watchNamespace(api, namespace, abortController); + this.watchNamespace(namespace, abortController); } }) .catch(noop); // ignore DOMExceptions + } else { + this.watchNamespace("", abortController); } - for (const api of clusterScopedApis) { - /** - * if the api is cluster scoped then we will never assign to `loadedNamespaces` - * and thus `store.namespacesReady` will never resolve. Futhermore, we don't care - * about watching namespaces. - */ - apiManager.getStore(api).watchNamespace(api, "", abortController); - } - - return () => { - abortController.abort(); - }; + return () => abortController.abort(); } - private watchNamespace(api: KubeApi, namespace: string, abortController: AbortController) { + private watchNamespace(namespace: string, abortController: AbortController) { let timedRetry: NodeJS.Timeout; - const watch = () => api.watch({ + const watch = () => this.api.watch({ namespace, abortController, callback diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 3933ff94b6..a0b20d1e2d 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -38,3 +38,4 @@ export * from "./convertMemory"; export * from "./convertCpu"; export * from "./metricUnitsToNumber"; export * from "./display-booleans"; +export * from "./isMiddleClick"; diff --git a/src/renderer/utils/isMiddleClick.ts b/src/renderer/utils/isMiddleClick.ts new file mode 100644 index 0000000000..1fcb3ccff8 --- /dev/null +++ b/src/renderer/utils/isMiddleClick.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type React from "react"; + +// Helper for inlining middleClick checks +//
console.log('do some action'))}> +// +// +// + +export function isMiddleClick(callback: (evt: E) => any) { + return function (evt: E) { + if(evt.button === 1) { + return callback(evt); + } + }; +} diff --git a/yarn.lock b/yarn.lock index 8a812a5482..0e543a65de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1034,10 +1034,10 @@ lz-string "^1.4.4" pretty-format "^26.6.2" -"@testing-library/jest-dom@^5.11.10": - version "5.11.10" - resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.10.tgz#1cd90715023e1627f5ed26ab3b38e6f22d77046c" - integrity sha512-FuKiq5xuk44Fqm0000Z9w0hjOdwZRNzgx7xGGxQYepWFZy+OYUMOT/wPI4nLYXCaVltNVpU1W/qmD88wLWDsqQ== +"@testing-library/jest-dom@^5.13.0": + version "5.13.0" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.13.0.tgz#0a365684e2c1159f857f5915be50089fc5657df0" + integrity sha512-+jXXTn8GjRnZkJfzG/tqK/2Q7dGlBInR412WE7Aml7CT3wdSpx5dMQC0HOwVQoZ3cNTmQUy8fCVGUV/Zhoyvcw== dependencies: "@babel/runtime" "^7.9.2" "@types/testing-library__jest-dom" "^5.9.1" @@ -4419,24 +4419,21 @@ css-element-queries@^1.2.3: resolved "https://registry.yarnpkg.com/css-element-queries/-/css-element-queries-1.2.3.tgz#e14940b1fcd4bf0da60ea4145d05742d7172e516" integrity sha512-QK9uovYmKTsV2GXWQiMOByVNrLn2qz6m3P7vWpOR4IdD6I3iXoDw5qtgJEN3Xq7gIbdHVKvzHjdAtcl+4Arc4Q== -css-loader@^3.5.3: - version "3.6.0" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" - integrity sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ== +css-loader@^5.2.6: + version "5.2.6" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.2.6.tgz#c3c82ab77fea1f360e587d871a6811f4450cc8d1" + integrity sha512-0wyN5vXMQZu6BvjbrPdUJvkCzGEO24HC7IS7nW4llc6BBFC+zwR9CKtYGv63Puzsg10L/o12inMY5/2ByzfD6w== dependencies: - camelcase "^5.3.1" - cssesc "^3.0.0" - icss-utils "^4.1.1" - loader-utils "^1.2.3" - normalize-path "^3.0.0" - postcss "^7.0.32" - postcss-modules-extract-imports "^2.0.0" - postcss-modules-local-by-default "^3.0.2" - postcss-modules-scope "^2.2.0" - postcss-modules-values "^3.0.0" + icss-utils "^5.1.0" + loader-utils "^2.0.0" + postcss "^8.2.15" + postcss-modules-extract-imports "^3.0.0" + postcss-modules-local-by-default "^4.0.0" + postcss-modules-scope "^3.0.0" + postcss-modules-values "^4.0.0" postcss-value-parser "^4.1.0" - schema-utils "^2.7.0" - semver "^6.3.0" + schema-utils "^3.0.0" + semver "^7.3.5" css-parse@~2.0.0: version "2.0.0" @@ -7308,13 +7305,18 @@ icss-utils@^3.0.1: dependencies: postcss "^6.0.2" -icss-utils@^4.0.0, icss-utils@^4.1.1: +icss-utils@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== dependencies: postcss "^7.0.14" +icss-utils@^5.0.0, icss-utils@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" + integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== + identity-obj-proxy@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" @@ -10020,10 +10022,10 @@ nan@^2.12.1, nan@^2.13.2, nan@^2.14.0: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== -nanoid@^3.1.22: - version "3.1.22" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844" - integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ== +nanoid@^3.1.23: + version "3.1.23" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" + integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== nanomatch@^1.2.9: version "1.2.13" @@ -11467,38 +11469,33 @@ postcss-loader@~3.0.0: postcss-load-config "^2.0.0" schema-utils "^1.0.0" -postcss-modules-extract-imports@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" - integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== - dependencies: - postcss "^7.0.5" +postcss-modules-extract-imports@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" + integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== -postcss-modules-local-by-default@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz#bb14e0cc78279d504dbdcbfd7e0ca28993ffbbb0" - integrity sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw== +postcss-modules-local-by-default@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" + integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== dependencies: - icss-utils "^4.1.1" - postcss "^7.0.32" + icss-utils "^5.0.0" postcss-selector-parser "^6.0.2" postcss-value-parser "^4.1.0" -postcss-modules-scope@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" - integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ== - dependencies: - postcss "^7.0.6" - postcss-selector-parser "^6.0.0" - -postcss-modules-values@^3.0.0: +postcss-modules-scope@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" - integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg== + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" + integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== dependencies: - icss-utils "^4.0.0" - postcss "^7.0.6" + postcss-selector-parser "^6.0.4" + +postcss-modules-values@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" + integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== + dependencies: + icss-utils "^5.0.0" postcss-nested@5.0.5: version "5.0.5" @@ -11507,14 +11504,6 @@ postcss-nested@5.0.5: dependencies: postcss-selector-parser "^6.0.4" -postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.4: - version "6.0.5" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.5.tgz#042d74e137db83e6f294712096cb413f5aa612c4" - integrity sha512-aFYPoYmXbZ1V6HZaSvat08M97A8HqO6Pjz+PiNpw/DhuRrC72XWAdp3hL6wusDCN31sSmcZyMGa2hZEuX+Xfhg== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - postcss-selector-parser@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" @@ -11524,6 +11513,14 @@ postcss-selector-parser@^6.0.2: indexes-of "^1.0.1" uniq "^1.0.1" +postcss-selector-parser@^6.0.4: + version "6.0.5" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.5.tgz#042d74e137db83e6f294712096cb413f5aa612c4" + integrity sha512-aFYPoYmXbZ1V6HZaSvat08M97A8HqO6Pjz+PiNpw/DhuRrC72XWAdp3hL6wusDCN31sSmcZyMGa2hZEuX+Xfhg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-value-parser@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" @@ -11543,7 +11540,7 @@ postcss@^6.0.14, postcss@^6.0.2, postcss@^6.0.9: source-map "^0.6.1" supports-color "^5.4.0" -postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: +postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.27: version "7.0.35" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== @@ -11552,14 +11549,14 @@ postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0. source-map "^0.6.1" supports-color "^6.1.0" -postcss@^8.1.6, postcss@^8.2.1, postcss@^8.2.14: - version "8.2.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.14.tgz#dcf313eb8247b3ce8078d048c0e8262ca565ad2b" - integrity sha512-+jD0ZijcvyCqPQo/m/CW0UcARpdFylq04of+Q7RKX6f/Tu+dvpUI/9Sp81+i6/vJThnOBX09Quw0ZLOVwpzX3w== +postcss@^8.1.6, postcss@^8.2.1, postcss@^8.2.14, postcss@^8.2.15: + version "8.3.0" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.0.tgz#b1a713f6172ca427e3f05ef1303de8b65683325f" + integrity sha512-+ogXpdAjWGa+fdYY5BQ96V/6tAo+TdSSIMP5huJBIygdWwKtVoB5JWZ7yUd4xZ8r+8Kvvx4nyg/PQ071H4UtcQ== dependencies: colorette "^1.2.2" - nanoid "^3.1.22" - source-map "^0.6.1" + nanoid "^3.1.23" + source-map-js "^0.6.2" postinstall-postinstall@^2.1.0: version "2.1.0" @@ -12830,7 +12827,7 @@ schema-utils@1.0.0, schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" -schema-utils@^2.6.1, schema-utils@^2.6.5, schema-utils@^2.7.0: +schema-utils@^2.6.1, schema-utils@^2.6.5: version "2.7.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== @@ -12892,23 +12889,18 @@ semver-diff@^3.1.1: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@7.x, semver@^7.3.2: - version "7.3.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" - integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== - -semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^7.2.1, semver@^7.3.4: +semver@7.x, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: version "7.3.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== dependencies: lru-cache "^6.0.0" +semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" @@ -13278,6 +13270,11 @@ source-list-map@^2.0.0: resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== +source-map-js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" + integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== + source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"