diff --git a/.github/workflows/publish-master-npm.yml b/.github/workflows/publish-master-npm.yml new file mode 100644 index 0000000000..2be9721d3e --- /dev/null +++ b/.github/workflows/publish-master-npm.yml @@ -0,0 +1,35 @@ +name: Publish NPM Package `master` +on: + push: + branches: + - master +jobs: + publish: + name: Publish NPM Package `master` + runs-on: ubuntu-latest + if: | + ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'area/extension') }} + strategy: + matrix: + node-version: [12.x] + steps: + - name: Checkout Release + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Using Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Generate NPM package + run: | + make build-npm + + - name: publish new release + run: | + make publish-npm + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_RELEASE_TAG: master diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-release-npm.yml similarity index 90% rename from .github/workflows/publish-npm.yml rename to .github/workflows/publish-release-npm.yml index 2502249ee3..5a8bd9aff5 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-release-npm.yml @@ -1,11 +1,11 @@ -name: Publish NPM +name: Publish NPM Package Release on: release: types: - published jobs: publish: - name: Publish NPM + name: Publish NPM Package Release runs-on: ubuntu-latest strategy: matrix: diff --git a/Makefile b/Makefile index fc764d0a94..fba4e9b98e 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ CMD_ARGS = $(filter-out $@,$(MAKECMDGOALS)) %: @: +NPM_RELEASE_TAG ?= latest EXTENSIONS_DIR = ./extensions extensions = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}) extension_node_modules = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}/node_modules) @@ -82,8 +83,12 @@ $(extension_node_modules): node_modules $(extension_dists): src/extensions/npm/extensions/dist cd $(@:/dist=) && ../../node_modules/.bin/npm run build +.PHONY: clean-old-extensions +clean-old-extensions: + find ./extensions -mindepth 1 -maxdepth 1 -type d '!' -exec test -e '{}/package.json' \; -exec rm -rf {} \; + .PHONY: build-extensions -build-extensions: node_modules $(extension_node_modules) $(extension_dists) +build-extensions: node_modules clean-old-extensions $(extension_node_modules) $(extension_dists) .PHONY: test-extensions test-extensions: $(extension_node_modules) @@ -110,7 +115,8 @@ build-extension-types: node_modules src/extensions/npm/extensions/dist .PHONY: publish-npm publish-npm: node_modules build-npm ./node_modules/.bin/npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" - cd src/extensions/npm/extensions && npm publish --access=public + cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG) + git restore src/extensions/npm/extensions/package.json .PHONY: docs docs: diff --git a/build/set_build_version.ts b/build/set_build_version.ts index df48a1da13..13d46d4ff3 100644 --- a/build/set_build_version.ts +++ b/build/set_build_version.ts @@ -22,6 +22,7 @@ import * as fs from "fs"; import * as path from "path"; import appInfo from "../package.json"; import semver from "semver"; +import fastGlob from "fast-glob"; const packagePath = path.join(__dirname, "../package.json"); const versionInfo = semver.parse(appInfo.version); @@ -41,3 +42,14 @@ if (versionInfo.prerelease) { fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`); + +const extensionManifests = fastGlob.sync(["extensions/*/package.json"]); + +for (const manifestPath of extensionManifests) { + const packagePath = path.join(__dirname, "..", manifestPath); + + import(packagePath).then((packageInfo) => { + packageInfo.default.version = `${versionInfo.raw}.${Date.now()}`; + fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo.default, null, 2)}\n`); + }); +} diff --git a/build/set_npm_version.ts b/build/set_npm_version.ts index b7614103a2..c0b24b54a6 100644 --- a/build/set_npm_version.ts +++ b/build/set_npm_version.ts @@ -22,8 +22,20 @@ import * as fs from "fs"; import * as path from "path"; import packageInfo from "../src/extensions/npm/extensions/package.json"; import appInfo from "../package.json"; +import { SemVer } from "semver"; +import { execSync } from "child_process"; -const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json"); +const { NPM_RELEASE_TAG = "latest" } = process.env; +const version = new SemVer(appInfo.version); -packageInfo.version = appInfo.version; -fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo, null, 2)}\n`); +if (NPM_RELEASE_TAG !== "latest") { + const gitRef = execSync("git rev-parse --short HEAD", { + encoding: "utf-8", + }); + + version.inc("prerelease", `git.${gitRef.trim()}`); +} + +packageInfo.version = version.format(); + +fs.writeFileSync(path.join(__dirname, "../src/extensions/npm/extensions/package.json"), `${JSON.stringify(packageInfo, null, 2)}\n`); diff --git a/docs/extensions/typedoc-readme.md.tpl b/docs/extensions/typedoc-readme.md.tpl index bce44cc96f..fe0bdb73a9 100644 --- a/docs/extensions/typedoc-readme.md.tpl +++ b/docs/extensions/typedoc-readme.md.tpl @@ -2,6 +2,6 @@ ## APIs -- [Common](modules/_common_api_index_.md) -- [Main](modules/_main_api_index_.md) -- [Renderer](modules/_renderer_api_index_.md) +- [Common](modules/common.md) +- [Main](modules/main.md) +- [Renderer](modules/renderer.md) diff --git a/extensions/kube-object-event-status/package.json b/extensions/kube-object-event-status/package.json index eb9a791c3f..429bb98827 100644 --- a/extensions/kube-object-event-status/package.json +++ b/extensions/kube-object-event-status/package.json @@ -1,6 +1,6 @@ { "name": "kube-object-event-status", - "version": "0.1.0", + "version": "0.0.1", "description": "Adds kube object status from events", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/metrics-cluster-feature/package.json b/extensions/metrics-cluster-feature/package.json index c47334cea6..8683b75737 100644 --- a/extensions/metrics-cluster-feature/package.json +++ b/extensions/metrics-cluster-feature/package.json @@ -1,6 +1,6 @@ { "name": "lens-metrics-cluster-feature", - "version": "0.1.0", + "version": "0.0.1", "description": "Lens metrics cluster feature", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/node-menu/package.json b/extensions/node-menu/package.json index 2790e40139..dcec7757ec 100644 --- a/extensions/node-menu/package.json +++ b/extensions/node-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-node-menu", - "version": "0.1.0", + "version": "0.0.1", "description": "Lens node menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/pod-menu/package.json b/extensions/pod-menu/package.json index 1ff9d23d42..71ca4c2f3e 100644 --- a/extensions/pod-menu/package.json +++ b/extensions/pod-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-pod-menu", - "version": "0.1.0", + "version": "0.0.1", "description": "Lens pod menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/pod-menu/renderer.tsx b/extensions/pod-menu/renderer.tsx index 66da38f7ba..d25ab12d3f 100644 --- a/extensions/pod-menu/renderer.tsx +++ b/extensions/pod-menu/renderer.tsx @@ -20,12 +20,20 @@ */ import { Renderer } from "@k8slens/extensions"; +import { PodAttachMenu, PodAttachMenuProps } from "./src/attach-menu"; import { PodShellMenu, PodShellMenuProps } from "./src/shell-menu"; import { PodLogsMenu, PodLogsMenuProps } from "./src/logs-menu"; import React from "react"; export default class PodMenuRendererExtension extends Renderer.LensExtension { kubeObjectMenuItems = [ + { + kind: "Pod", + apiVersions: ["v1"], + components: { + MenuItem: (props: PodAttachMenuProps) => + } + }, { kind: "Pod", apiVersions: ["v1"], diff --git a/extensions/pod-menu/src/attach-menu.tsx b/extensions/pod-menu/src/attach-menu.tsx new file mode 100644 index 0000000000..bb43d151bc --- /dev/null +++ b/extensions/pod-menu/src/attach-menu.tsx @@ -0,0 +1,101 @@ +/** + * 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 { Renderer, Common } from "@k8slens/extensions"; + +type Pod = Renderer.K8sApi.Pod; + +const { + Component: { + createTerminalTab, + terminalStore, + MenuItem, + Icon, + SubMenu, + StatusBrick, + }, + Navigation, +} = Renderer; +const { + Util, +} = Common; + +export interface PodAttachMenuProps extends Renderer.Component.KubeObjectMenuProps { +} + +export class PodAttachMenu extends React.Component { + async attachToPod(container?: string) { + const { object: pod } = this.props; + const containerParam = container ? `-c ${container}` : ""; + let command = `kubectl attach -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam}`; + + if (window.navigator.platform !== "Win32") { + command = `exec ${command}`; + } + + const shell = createTerminalTab({ + title: `Pod: ${pod.getName()} (namespace: ${pod.getNs()}) [Attached]` + }); + + terminalStore.sendCommand(command, { + enter: true, + tabId: shell.id + }); + + Navigation.hideDetails(); + } + + render() { + const { object, toolbar } = this.props; + const containers = object.getRunningContainers(); + + if (!containers.length) return null; + + return ( + this.attachToPod(containers[0].name))}> + + Attach Pod + {containers.length > 1 && ( + <> + + + { + containers.map(container => { + const { name } = container; + + return ( + this.attachToPod(name))} className="flex align-center"> + + {name} + + ); + }) + } + + + )} + + ); + } +} diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index ca5e2aaad3..24ad38dcfc 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -410,7 +410,7 @@ describe("Lens cluster pages", () => { await app.client.click(".list .TableRow:first-child"); await app.client.waitForVisible(".Drawer"); await app.client.waitForVisible(`ul.KubeObjectMenu li.MenuItem i[title="Logs"]`); - await app.client.click(".drawer-title .Menu li:nth-child(2)"); + await app.client.click("ul.KubeObjectMenu li.MenuItem i[title='Logs']"); // Check if controls are available await app.client.waitForVisible(".LogList .VirtualList"); await app.client.waitForVisible(".LogResourceSelector"); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index c94b31aa93..bbe411047b 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -64,6 +64,8 @@ export async function waitForMinikubeDashboard(app: Application) { await app.client.setValue(".Input.SearchInput input", "minikube"); await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); + await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube"); + await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root"); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); await app.client.frame("minikube"); diff --git a/package.json b/package.json index 4400cf8d72..49f7cb0653 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.0.0-beta.6", + "version": "5.0.0-beta.7", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", @@ -39,8 +39,8 @@ "lint": "yarn run eslint --ext js,ts,tsx --max-warnings=0 .", "lint:fix": "yarn run lint --fix", "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", - "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", - "typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts", + "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build", + "typedocs-extensions-api": "yarn run typedoc src/extensions/extension-api.ts", "version-checkout": "cat package.json | jq '.version' -r | xargs printf \"release/v%s\" | xargs git checkout -b", "version-commit": "cat package.json | jq '.version' -r | xargs printf \"release v%s\" | git commit --no-edit -s -F -", "version": "yarn run version-checkout && git add package.json && yarn run version-commit", @@ -181,7 +181,7 @@ "dependencies": { "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", - "@kubernetes/client-node": "^0.12.0", + "@kubernetes/client-node": "^0.14.3", "abort-controller": "^3.0.0", "array-move": "^3.0.1", "auto-bind": "^4.0.0", @@ -203,6 +203,7 @@ "handlebars": "^4.7.7", "http-proxy": "^1.18.1", "immer": "^8.0.1", + "joi": "^17.4.0", "js-yaml": "^3.14.0", "jsdom": "^16.4.0", "jsonpath": "^1.0.2", @@ -214,7 +215,7 @@ "mobx-observable-history": "^2.0.1", "mobx-react": "^7.1.0", "mock-fs": "^4.12.0", - "moment": "^2.26.0", + "moment": "^2.29.1", "moment-timezone": "^0.5.33", "node-pty": "^0.9.0", "npm": "^6.14.8", @@ -273,14 +274,14 @@ "@types/mini-css-extract-plugin": "^0.9.1", "@types/mock-fs": "^4.10.0", "@types/module-alias": "^2.0.0", - "@types/node": "^12.12.45", + "@types/node": "12.20", "@types/npm": "^2.0.31", "@types/progress-bar-webpack-plugin": "^2.1.0", "@types/proper-lockfile": "^4.1.1", "@types/randomcolor": "^0.5.5", "@types/react": "^17.0.0", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^17.0.0", + "@types/react-dom": "^17.0.6", "@types/react-router-dom": "^5.1.6", "@types/react-select": "3.1.2", "@types/react-table": "^7.7.0", @@ -289,7 +290,7 @@ "@types/request": "^2.48.5", "@types/request-promise-native": "^1.0.17", "@types/semver": "^7.2.0", - "@types/sharp": "^0.26.0", + "@types/sharp": "^0.28.3", "@types/shelljs": "^0.8.8", "@types/spdy": "^3.4.4", "@types/tar": "^4.0.4", @@ -335,7 +336,7 @@ "jest-mock-extended": "^1.0.10", "make-plural": "^6.2.2", "mini-css-extract-plugin": "^1.6.0", - "node-loader": "^0.6.0", + "node-loader": "^1.0.3", "node-sass": "^4.14.1", "nodemon": "^2.0.4", "open": "^7.3.1", @@ -363,8 +364,8 @@ "ts-node": "^8.10.2", "type-fest": "^1.0.2", "typed-emitter": "^1.3.1", - "typedoc": "0.17.0-3", - "typedoc-plugin-markdown": "^2.4.0", + "typedoc": "0.21.0-beta.2", + "typedoc-plugin-markdown": "^3.9.0", "typeface-roboto": "^0.0.75", "typescript": "^4.3.2", "typescript-plugin-css-modules": "^3.2.0", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 5117e8d603..3b87f53670 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -22,8 +22,10 @@ import fs from "fs"; import mockFs from "mock-fs"; import yaml from "js-yaml"; +import path from "path"; +import fse from "fs-extra"; import { Cluster } from "../../main/cluster"; -import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; +import { ClusterId, ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { Console } from "console"; import { stdout, stderr } from "process"; @@ -54,6 +56,15 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; +function embed(clusterId: ClusterId, contents: any): string { + const absPath = ClusterStore.getCustomKubeConfigPath(clusterId); + + fse.ensureDirSync(path.dirname(absPath)); + fse.writeFileSync(absPath, contents, { encoding: "utf-8", mode: 0o600 }); + + return absPath; +} + jest.mock("electron", () => { return { app: { @@ -102,7 +113,7 @@ describe("empty config", () => { icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig) + kubeConfigPath: embed("foo", kubeconfig) }) ); }); @@ -130,7 +141,7 @@ describe("empty config", () => { preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig) + kubeConfigPath: embed("prod", kubeconfig) }), new Cluster({ id: "dev", @@ -138,7 +149,7 @@ describe("empty config", () => { preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig) + kubeConfigPath: embed("dev", kubeconfig) }) ); }); @@ -149,7 +160,7 @@ describe("empty config", () => { }); it("check if cluster's kubeconfig file saved", () => { - const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); + const file = embed("boo", "kubeconfig"); expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); }); @@ -160,6 +171,7 @@ describe("config with existing clusters", () => { beforeEach(() => { ClusterStore.resetInstance(); const mockOpts = { + "temp-kube-config": kubeconfig, "tmp": { "lens-cluster-store.json": JSON.stringify({ __internal__: { @@ -170,20 +182,20 @@ describe("config with existing clusters", () => { clusters: [ { id: "cluster1", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./temp-kube-config", contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "default" }, { id: "cluster2", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./temp-kube-config", contextName: "foo2", preferences: { terminalCWD: "/foo2" } }, { id: "cluster3", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./temp-kube-config", contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "foo", @@ -256,6 +268,8 @@ users: ClusterStore.resetInstance(); const mockOpts = { + "invalid-kube-config": invalidKubeconfig, + "valid-kube-config": kubeconfig, "tmp": { "lens-cluster-store.json": JSON.stringify({ __internal__: { @@ -266,14 +280,14 @@ users: clusters: [ { id: "cluster1", - kubeConfigPath: invalidKubeconfig, + kubeConfigPath: "./invalid-kube-config", contextName: "test", preferences: { terminalCWD: "/foo" }, workspace: "foo", }, { id: "cluster2", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./valid-kube-config", contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "default" diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts index 98167b91d4..5d2bd35344 100644 --- a/src/common/__tests__/kube-helpers.test.ts +++ b/src/common/__tests__/kube-helpers.test.ts @@ -20,7 +20,7 @@ */ import { KubeConfig } from "@kubernetes/client-node"; -import { validateKubeConfig, loadConfig, getNodeWarningConditions } from "../kube-helpers"; +import { validateKubeConfig, loadConfigFromString, getNodeWarningConditions } from "../kube-helpers"; const kubeconfig = ` apiVersion: v1 @@ -59,8 +59,6 @@ users: command: foo `; -const kc = new KubeConfig(); - interface kubeconfig { apiVersion: string, clusters: [{ @@ -88,6 +86,8 @@ let mockKubeConfig: kubeconfig; describe("kube helpers", () => { describe("validateKubeconfig", () => { + const kc = new KubeConfig(); + beforeAll(() => { kc.loadFromString(kubeconfig); }); @@ -164,12 +164,12 @@ describe("kube helpers", () => { it("invalid yaml string", () => { const invalidYAMLString = "fancy foo config"; - expect(() => loadConfig(invalidYAMLString)).toThrowError("must be an object"); + expect(loadConfigFromString(invalidYAMLString).error).toBeInstanceOf(Error); }); it("empty contexts", () => { - const emptyContexts = `apiVersion: v1\ncontexts:`; + const emptyContexts = `apiVersion: v1\ncontexts: []`; - expect(() => loadConfig(emptyContexts)).not.toThrow(); + expect(loadConfigFromString(emptyContexts).error).toBeUndefined(); }); }); @@ -200,17 +200,17 @@ describe("kube helpers", () => { }); it("single context is ok", async () => { - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); + expect(config.getCurrentContext()).toBe("minikube"); }); it("multiple context is ok", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: "cluster-2"}); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(2); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(2); }); }); @@ -243,40 +243,40 @@ describe("kube helpers", () => { it("empty name in context causes it to be removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: ""}); expect(mockKubeConfig.contexts.length).toBe(2); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(1); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); }); it("empty cluster in context causes it to be removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "", user: "cluster-2"}, name: "cluster-2"}); expect(mockKubeConfig.contexts.length).toBe(2); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(1); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); }); it("empty user in context causes it to be removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"}); expect(mockKubeConfig.contexts.length).toBe(2); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(1); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); }); it("invalid context in between valid contexts is removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"}); mockKubeConfig.contexts.push({context: {cluster: "cluster-3", user: "cluster-3"}, name: "cluster-3"}); expect(mockKubeConfig.contexts.length).toBe(3); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(2); - expect(kc.contexts[0].name).toBe("minikube"); - expect(kc.contexts[1].name).toBe("cluster-3"); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(2); + expect(config.contexts[0].name).toBe("minikube"); + expect(config.contexts[1].name).toBe("cluster-3"); }); }); }); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index daa515cf8c..465c598bb0 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -66,19 +66,6 @@ describe("user store tests", () => { expect(us.lastSeenAppVersion).toBe("1.2.3"); }); - it("allows adding and listing seen contexts", () => { - const us = UserStore.getInstance(); - - us.seenContexts.add("foo"); - expect(us.seenContexts.size).toBe(1); - - us.seenContexts.add("foo"); - us.seenContexts.add("bar"); - expect(us.seenContexts.size).toBe(2); // check 'foo' isn't added twice - expect(us.seenContexts.has("foo")).toBe(true); - expect(us.seenContexts.has("bar")).toBe(true); - }); - it("allows setting and getting preferences", () => { const us = UserStore.getInstance(); diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index dc6d36d023..67d82e3501 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -104,6 +104,7 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) }, @@ -112,6 +113,7 @@ export class KubernetesCluster extends CatalogEntity ClusterStore.getInstance().removeById(this.metadata.uid), confirm: { @@ -123,6 +125,7 @@ export class KubernetesCluster extends CatalogEntity { requestMain(clusterDisconnectHandler, this.metadata.uid); } @@ -130,6 +133,7 @@ export class KubernetesCluster extends CatalogEntity { context.navigate(`/cluster/${this.metadata.uid}`); } @@ -147,7 +151,7 @@ export class KubernetesClusterCategory extends CatalogCategory { public readonly kind = "CatalogCategory"; public metadata = { name: "Kubernetes Clusters", - icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line + icon: require(`!!raw-loader!./icons/kubernetes.svg`).default, // eslint-disable-line }; public spec: CatalogCategorySpec = { group: "entity.k8slens.dev", diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index afc15d4c64..6c58497204 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -96,9 +96,25 @@ export interface CatalogEntityActionContext { } export interface CatalogEntityContextMenu { + /** + * Menu title + */ title: string; - onlyVisibleForSource?: string; // show only if empty or if matches with entity source + /** + * Menu icon + */ + icon?: string; + /** + * Show only if empty or if value matches with entity.metadata.source + */ + onlyVisibleForSource?: string; + /** + * OnClick handler + */ onClick: () => void | Promise; + /** + * Confirm click with a message + */ confirm?: { message: string; } @@ -175,7 +191,6 @@ export abstract class CatalogEntity< } public abstract onRun?(context: CatalogEntityActionContext): void | Promise; - public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise; public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; } diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 9d8bc864cb..60968efd06 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -26,11 +26,9 @@ import { action, comparer, computed, makeObservable, observable, reaction } from import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store"; +import * as uuid from "uuid"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; -import { dumpConfigYaml } from "./kube-helpers"; -import { saveToAppFiles } from "./utils/saveToAppFiles"; -import type { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { disposer, noop, toJS } from "./utils"; @@ -116,19 +114,10 @@ export class ClusterStore extends BaseStore { return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); } - static getCustomKubeConfigPath(clusterId: ClusterId): string { + static getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string { return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); } - static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { - const filePath = ClusterStore.getCustomKubeConfigPath(clusterId); - const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); - - saveToAppFiles(filePath, fileContents, { mode: 0o600 }); - - return filePath; - } - @observable clusters = observable.map(); @observable removedClusters = observable.map(); diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 737c767cf2..aff05e68c6 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -28,6 +28,8 @@ import logger from "../main/logger"; import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; +import { resolvePath } from "./utils"; +import Joi from "joi"; export type KubeConfigValidationOpts = { validateCluster?: boolean; @@ -37,50 +39,108 @@ export type KubeConfigValidationOpts = { export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); -function resolveTilde(filePath: string) { - if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { - return filePath.replace("~", os.homedir()); - } +export function loadConfigFromFileSync(filePath: string): ConfigResult { + const content = fse.readFileSync(resolvePath(filePath), "utf-8"); - return filePath; + return loadConfigFromString(content); } -function readResolvedPathSync(filePath: string): string { - return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8"); +export async function loadConfigFromFile(filePath: string): Promise { + const content = await fse.readFile(resolvePath(filePath), "utf-8"); + + return loadConfigFromString(content); } -function checkRawCluster(rawCluster: any): boolean { - return Boolean(rawCluster?.name && rawCluster?.cluster?.server); -} +const clusterSchema = Joi.object({ + name: Joi + .string() + .min(1) + .required(), + cluster: Joi + .object({ + server: Joi + .string() + .min(1) + .required(), + }) + .required(), +}); -function checkRawUser(rawUser: any): boolean { - return Boolean(rawUser?.name); -} +const userSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), +}); -function checkRawContext(rawContext: any): boolean { - return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user); -} +const contextSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), + context: Joi.object({ + cluster: Joi.string() + .min(1) + .required(), + user: Joi.string() + .min(1) + .required(), + }), +}); + +const kubeConfigSchema = Joi + .object({ + users: Joi + .array() + .items(userSchema) + .optional(), + clusters: Joi + .array() + .items(clusterSchema) + .optional(), + contexts: Joi + .array() + .items(contextSchema) + .optional(), + "current-context": Joi + .string() + .min(1) + .optional(), + }) + .required(); export interface KubeConfigOptions { clusters: Cluster[]; users: User[]; contexts: Context[]; - currentContext: string; + currentContext?: string; } -function loadToOptions(rawYaml: string): KubeConfigOptions { - const obj = yaml.safeLoad(rawYaml); +export interface OptionsResult { + options: KubeConfigOptions; + error: Joi.ValidationError; +} - if (typeof obj !== "object" || !obj) { - throw new TypeError("KubeConfig root entry must be an object"); - } +function loadToOptions(rawYaml: string): OptionsResult { + const parsed = yaml.safeLoad(rawYaml); + const { error } = kubeConfigSchema.validate(parsed, { + abortEarly: false, + allowUnknown: true, + }); + const { value } = kubeConfigSchema.validate(parsed, { + abortEarly: false, + allowUnknown: true, + stripUnknown: { + arrays: true, + } + }); + const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = value ?? {}; + const clusters = newClusters(rawClusters); + const users = newUsers(rawUsers); + const contexts = newContexts(rawContexts); - const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj; - const clusters = newClusters(rawClusters?.filter(checkRawCluster)); - const users = newUsers(rawUsers?.filter(checkRawUser)); - const contexts = newContexts(rawContexts?.filter(checkRawContext)); - - return { clusters, users, contexts, currentContext }; + return { + options: { clusters, users, contexts, currentContext }, + error, + }; } export function loadFromOptions(options: KubeConfigOptions): KubeConfig { @@ -92,67 +152,44 @@ export function loadFromOptions(options: KubeConfigOptions): KubeConfig { return kc; } -export function loadConfig(pathOrContent?: string): KubeConfig { - return loadConfigFromString( - fse.pathExistsSync(pathOrContent) - ? readResolvedPathSync(pathOrContent) - : pathOrContent - ); +export interface ConfigResult { + config: KubeConfig; + error: Joi.ValidationError; } -export function loadConfigFromString(content: string): KubeConfig { - return loadFromOptions(loadToOptions(content)); +export function loadConfigFromString(content: string): ConfigResult { + const { options, error } = loadToOptions(content); + + return { + config: loadFromOptions(options), + error, + }; } -/** - * KubeConfig is valid when there's at least one of each defined: - * - User - * - Cluster - * - Context - * @param config KubeConfig to check - */ -export function validateConfig(config: KubeConfig | string): KubeConfig { - if (typeof config == "string") { - config = loadConfig(config); - } - logger.debug(`validating kube config: ${JSON.stringify(config)}`); - - if (!config.users || config.users.length == 0) { - throw new Error("No users provided in config"); - } - - if (!config.clusters || config.clusters.length == 0) { - throw new Error("No clusters provided in config"); - } - - if (!config.contexts || config.contexts.length == 0) { - throw new Error("No contexts provided in config"); - } - - return config; +export interface SplitConfigEntry { + config: KubeConfig, + error?: string; } /** * Breaks kube config into several configs. Each context as it own KubeConfig object */ -export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { - const configs: KubeConfig[] = []; +export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] { + const { contexts = [] } = kubeConfig; - if (!kubeConfig.contexts) { - return configs; - } - kubeConfig.contexts.forEach(ctx => { - const kc = new KubeConfig(); + return contexts.map(context => { + const config = new KubeConfig(); - kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); - kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n); - kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n); - kc.setCurrentContext(ctx.name); + config.clusters = [kubeConfig.getCluster(context.cluster)].filter(Boolean); + config.users = [kubeConfig.getUser(context.user)].filter(Boolean); + config.contexts = [kubeConfig.getContextObject(context.name)].filter(Boolean); + config.setCurrentContext(context.name); - configs.push(kc); + return { + config, + error: validateKubeConfig(config, context.name)?.toString(), + }; }); - - return configs; } export function dumpConfigYaml(kubeConfig: Partial): string { @@ -230,7 +267,7 @@ export function getNodeWarningConditions(node: V1Node) { * * Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes */ -export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | void { +export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined { try { // we only receive a single context, cluster & user object here so lets validate them as this // will be called when we add a new cluster to Lens @@ -267,6 +304,8 @@ export function validateKubeConfig(config: KubeConfig, contextName: string, vali return new ExecValidationNotFoundError(execCommand, isAbsolute); } } + + return undefined; } catch (error) { return error; } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 4722dbb367..f7ab221bb8 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -22,24 +22,19 @@ import type { ThemeId } from "../renderer/theme.store"; import { app, remote } from "electron"; import semver from "semver"; -import { readFile } from "fs-extra"; import { action, computed, observable, reaction, makeObservable } from "mobx"; import moment from "moment-timezone"; import { BaseStore } from "./base-store"; import migrations from "../migrations/user-store"; import { getAppVersion } from "./utils/app-version"; -import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers"; import { appEventBus } from "./event-bus"; -import logger from "../main/logger"; import path from "path"; import os from "os"; import { fileNameMigration } from "../migrations/user-store"; import { ObservableToggleSet, toJS } from "../renderer/utils"; export interface UserStoreModel { - kubeConfigPath: string; lastSeenAppVersion: string; - seenContexts: string[]; preferences: UserPreferencesModel; } @@ -47,7 +42,7 @@ export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; } -export interface KubeconfigSyncValue {} +export interface KubeconfigSyncValue { } export interface UserPreferencesModel { httpsProxy?: string; @@ -77,13 +72,6 @@ export class UserStore extends BaseStore { } @observable lastSeenAppVersion = "0.0.0"; - - /** - * used in add-cluster page for providing context - */ - @observable kubeConfigPath = kubeConfigDefaultPath; - @observable seenContexts = observable.set(); - @observable newContexts = observable.set(); @observable allowTelemetry = true; @observable allowUntrustedCAs = false; @observable colorTheme = UserStore.defaultTheme; @@ -121,10 +109,6 @@ export class UserStore extends BaseStore { await fileNameMigration(); await super.load(); - // refresh new contexts - await this.refreshNewContexts(); - reaction(() => this.kubeConfigPath, () => this.refreshNewContexts()); - if (app) { // track telemetry availability reaction(() => this.allowTelemetry, allowed => { @@ -180,15 +164,6 @@ export class UserStore extends BaseStore { this.hiddenTableColumns.get(tableId)?.toggle(columnId); } - @action - resetKubeConfigPath() { - this.kubeConfigPath = kubeConfigDefaultPath; - } - - @computed get isDefaultKubeConfigPath(): boolean { - return this.kubeConfigPath === kubeConfigDefaultPath; - } - @action async resetTheme() { await this.whenLoaded; @@ -206,44 +181,14 @@ export class UserStore extends BaseStore { this.localeTimezone = tz; } - protected async refreshNewContexts() { - try { - const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); - - if (kubeConfig) { - this.newContexts.clear(); - loadConfig(kubeConfig).getContexts() - .filter(ctx => ctx.cluster) - .filter(ctx => !this.seenContexts.has(ctx.name)) - .forEach(ctx => this.newContexts.add(ctx.name)); - } - } catch (err) { - logger.error(err); - this.resetKubeConfigPath(); - } - } - - @action - markNewContextsAsSeen() { - const { seenContexts, newContexts } = this; - - this.seenContexts.replace([...seenContexts, ...newContexts]); - this.newContexts.clear(); - } - @action protected async fromStore(data: Partial = {}) { - const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data; + const { lastSeenAppVersion, preferences } = data; if (lastSeenAppVersion) { this.lastSeenAppVersion = lastSeenAppVersion; } - if (kubeConfigPath) { - this.kubeConfigPath = kubeConfigPath; - } - this.seenContexts.replace(seenContexts); - if (!preferences) { return; } @@ -287,9 +232,7 @@ export class UserStore extends BaseStore { } const model: UserStoreModel = { - kubeConfigPath: this.kubeConfigPath, lastSeenAppVersion: this.lastSeenAppVersion, - seenContexts: Array.from(this.seenContexts), preferences: { httpsProxy: toJS(this.httpsProxy), shell: toJS(this.shell), diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 1c2f4c888f..34bd65755b 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -21,7 +21,9 @@ // Common utils (main OR renderer) -export const noop: any = () => { /* empty */ }; +export function noop(...args: T): void { + return void args; +} export * from "./app-version"; export * from "./autobind"; @@ -39,8 +41,8 @@ export * from "./getRandId"; export * from "./hash-set"; export * from "./n-fircate"; export * from "./openExternal"; +export * from "./paths"; export * from "./reject-promise"; -export * from "./saveToAppFiles"; export * from "./singleton"; export * from "./splitArray"; export * from "./tar"; diff --git a/__mocks__/@linguiMacro.ts b/src/common/utils/paths.ts similarity index 76% rename from __mocks__/@linguiMacro.ts rename to src/common/utils/paths.ts index 35fca119d9..391e1392f2 100644 --- a/__mocks__/@linguiMacro.ts +++ b/src/common/utils/paths.ts @@ -18,7 +18,18 @@ * 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 default { - Trans: ({ children }: { children: React.ReactNode }) => children, - t: (message: string) => message -}; + +import path from "path"; +import os from "os"; + +function resolveTilde(filePath: string) { + if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { + return filePath.replace("~", os.homedir()); + } + + return filePath; +} + +export function resolvePath(filePath: string): string { + return path.resolve(resolveTilde(filePath)); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index 6a55683d48..b8aa69e4d5 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -21,7 +21,6 @@ // App's common configuration for any process (main, renderer, build pipeline, etc.) import path from "path"; -import { SemVer } from "semver"; import packageInfo from "../../package.json"; import { defineGlobal } from "./utils/defineGlobal"; @@ -67,10 +66,4 @@ 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; - -// This explicitly ignores the prerelease info on the package version -const { major, minor, patch } = new SemVer(packageInfo.version); -const mmpVersion = [major, minor, patch].join("."); -const docsVersion = isProduction ? `v${mmpVersion}` : "latest"; - -export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`; +export const docsUrl = `https://docs.k8slens.dev/main/` as string; diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 8651455380..da2669550f 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -26,7 +26,7 @@ import { action, computed, makeObservable, observable, reaction, when } from "mo import path from "path"; import { getHostedCluster } from "../common/cluster-store"; import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; -import { Singleton, toJS } from "../common/utils"; +import { Disposer, Singleton, toJS } from "../common/utils"; import logger from "../main/logger"; import type { InstalledExtension } from "./extension-discovery"; import { ExtensionsStore } from "./extensions-store"; @@ -250,6 +250,7 @@ export class ExtensionLoader extends Singleton { registries.statusBarRegistry.add(extension.statusBarItems), registries.commandRegistry.add(extension.commands), registries.welcomeMenuRegistry.add(extension.welcomeMenus), + registries.catalogEntityDetailRegistry.add(extension.catalogEntityDetailItems), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { @@ -295,7 +296,7 @@ export class ExtensionLoader extends Singleton { }); } - protected autoInitExtensions(register: (ext: LensExtension) => Promise) { + protected autoInitExtensions(register: (ext: LensExtension) => Promise) { return reaction(() => this.toJSON(), installedExtensions => { for (const [extId, extension] of installedExtensions) { const alreadyInit = this.instances.has(extId); @@ -310,8 +311,7 @@ export class ExtensionLoader extends Singleton { const instance = new LensExtensionClass(extension); - instance.whenEnabled(() => register(instance)); - instance.enable(); + instance.enable(register); this.instances.set(extId, instance); } catch (err) { logger.error(`${logModule}: activation extension error`, { ext: extension, err }); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 6f03829121..f3910308fd 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -20,11 +20,11 @@ */ import type { InstalledExtension } from "./extension-discovery"; -import { action, observable, reaction, makeObservable } from "mobx"; +import { action, observable, makeObservable } from "mobx"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; import type { ProtocolHandlerRegistration } from "./registries"; -import { disposer } from "../common/utils"; +import { Disposer, disposer } from "../common/utils"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -83,59 +83,44 @@ export class LensExtension { } @action - async enable() { - if (this.isEnabled) return; - this.isEnabled = true; - this.onActivate?.(); - logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); + async enable(register: (ext: LensExtension) => Promise) { + if (this.isEnabled) { + return; + } + + try { + await this.onActivate(); + this.isEnabled = true; + + this[Disposers].push(...await register(this)); + logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); + } catch (error) { + logger.error(`[EXTENSION]: failed to activate ${this.name}@${this.version}: ${error}`); + } } @action async disable() { - if (!this.isEnabled) return; - this.isEnabled = false; - this.onDeactivate?.(); - this[Disposers](); - logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`); - } + if (!this.isEnabled) { + return; + } - toggle(enable?: boolean) { - if (typeof enable === "boolean") { - enable ? this.enable() : this.disable(); - } else { - this.isEnabled ? this.disable() : this.enable(); + this.isEnabled = false; + + try { + await this.onDeactivate(); + this[Disposers](); + logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`); + } catch (error) { + logger.error(`[EXTENSION]: disabling ${this.name}@${this.version} threw an error: ${error}`); } } - async whenEnabled(handlers: () => Promise) { - const disposers: Function[] = []; - const unregisterHandlers = () => { - disposers.forEach(unregister => unregister()); - disposers.length = 0; - }; - const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => { - if (isEnabled) { - const handlerDisposers = await handlers(); - - disposers.push(...handlerDisposers); - } else { - unregisterHandlers(); - } - }, { - fireImmediately: true - }); - - return () => { - unregisterHandlers(); - cancelReaction(); - }; - } - - protected onActivate(): void { + protected onActivate(): Promise | void { return; } - protected onDeactivate(): void { + protected onDeactivate(): Promise | void { return; } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index e9105dbcd9..fb26d383b0 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -20,7 +20,7 @@ */ import type { - AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, + AppPreferenceRegistration, CatalogEntityDetailRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration, } from "./registries"; import type { Cluster } from "../main/cluster"; @@ -43,6 +43,7 @@ export class LensRendererExtension extends LensExtension { kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = []; commands: CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; + catalogEntityDetailItems: CatalogEntityDetailRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/catalog-entity-detail-registry.ts b/src/extensions/registries/catalog-entity-detail-registry.ts new file mode 100644 index 0000000000..0ed00b7a8b --- /dev/null +++ b/src/extensions/registries/catalog-entity-detail-registry.ts @@ -0,0 +1,46 @@ +/** + * 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"; +import { BaseRegistry } from "./base-registry"; + +export interface CatalogEntityDetailComponents { + Details: React.ComponentType; +} + +export interface CatalogEntityDetailRegistration { + kind: string; + apiVersions: string[]; + components: CatalogEntityDetailComponents; + priority?: number; +} + +export class CatalogEntityDetailRegistry extends BaseRegistry { + getItemsForKind(kind: string, apiVersion: string) { + const items = this.getItems().filter((item) => { + return item.kind === kind && item.apiVersions.includes(apiVersion); + }); + + return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50)); + } +} + +export const catalogEntityDetailRegistry = new CatalogEntityDetailRegistry(); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index e3a6490d61..b9ba986521 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -33,4 +33,5 @@ export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./welcome-menu-registry"; export * from "./protocol-handler-registry"; +export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index c4650644da..af6d7e150d 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -29,7 +29,7 @@ import type stream from "stream"; import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; import logger from "../logger"; import type { KubeConfig } from "@kubernetes/client-node"; -import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers"; +import { loadConfigFromString, splitConfig } from "../../common/kube-helpers"; import { Cluster } from "../cluster"; import { catalogEntityFromCluster } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; @@ -130,18 +130,16 @@ export class KubeconfigSyncManager extends Singleton { } // exported for testing -export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] { +export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] { const validConfigs = []; - for (const contextConfig of splitConfig(config)) { - const error = validateKubeConfig(contextConfig, contextConfig.currentContext); - + for (const { config, error } of splitConfig(rootConfig)) { if (error) { - logger.debug(`${logPrefix} context failed validation: ${error}`, { context: contextConfig.currentContext, filePath }); + logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath }); } else { validConfigs.push({ kubeConfigPath: filePath, - contextName: contextConfig.currentContext, + contextName: config.currentContext, }); } } @@ -156,7 +154,13 @@ type RootSource = ObservableMap; export function computeDiff(contents: string, source: RootSource, filePath: string): void { runInAction(() => { try { - const rawModels = configToModels(loadConfigFromString(contents), filePath); + const { config, error } = loadConfigFromString(contents); + + if (error) { + logger.warn(`${logPrefix} encountered errors while loading config: ${error.message}`, { filePath, details: error.details }); + } + + const rawModels = configToModels(config, filePath); const models = new Map(rawModels.map(m => [m.contextName, m])); logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); diff --git a/src/main/cluster.ts b/src/main/cluster.ts index e5db4adbb3..df0da0a552 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -27,7 +27,7 @@ import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; -import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; +import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../common/kube-helpers"; import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; @@ -258,14 +258,14 @@ export class Cluster implements ClusterModel, ClusterState { this.id = model.id; this.updateModel(model); - const kubeconfig = this.getKubeconfig(); - const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); + const { config } = loadConfigFromFileSync(this.kubeConfigPath); + const validationError = validateKubeConfig(config, this.contextName); - if (error) { - throw error; + if (validationError) { + throw validationError; } - this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; + this.apiUrl = config.getCluster(config.getContextObject(this.contextName).cluster).server; if (ipcMain) { // for the time being, until renderer gets its own cluster type @@ -470,17 +470,20 @@ export class Cluster implements ClusterModel, ClusterState { this.allowedResources = await this.getAllowedResources(); } - protected getKubeconfig(): KubeConfig { - return loadConfig(this.kubeConfigPath); + async getKubeconfig(): Promise { + const { config } = await loadConfigFromFile(this.kubeConfigPath); + + return config; } /** * @internal */ async getProxyKubeconfig(): Promise { - const kubeconfigPath = await this.getProxyKubeconfigPath(); + const proxyKCPath = await this.getProxyKubeconfigPath(); + const { config } = await loadConfigFromFile(proxyKCPath); - return loadConfig(kubeconfigPath); + return config; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 0396f840a1..3abbfecd17 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -93,11 +93,7 @@ if (!app.requestSingleInstanceLock()) { for (const arg of process.argv) { if (arg.toLowerCase().startsWith("lens://")) { - try { - lprm.route(arg); - } catch (error) { - logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }); - } + lprm.route(arg); } } } @@ -107,11 +103,7 @@ app.on("second-instance", (event, argv) => { for (const arg of argv) { if (arg.toLowerCase().startsWith("lens://")) { - try { - lprm.route(arg); - } catch (error) { - logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }); - } + lprm.route(arg); } } @@ -196,7 +188,7 @@ app.on("ready", async () => { installDeveloperTools(); if (!startHidden) { - windowManager.initMainWindow(); + windowManager.ensureMainWindow(); } ipcMain.on(IpcRendererNavigationEvents.LOADED, () => { @@ -244,7 +236,7 @@ app.on("activate", (event, hasVisibleWindows) => { logger.info("APP:ACTIVATE", { hasVisibleWindows }); if (!hasVisibleWindows) { - WindowManager.getInstance(false)?.initMainWindow(false); + WindowManager.getInstance(false)?.ensureMainWindow(false); } }); @@ -274,12 +266,7 @@ app.on("will-quit", (event) => { app.on("open-url", (event, rawUrl) => { // lens:// protocol handler event.preventDefault(); - - try { - LensProtocolRouterMain.getInstance().route(rawUrl); - } catch (error) { - logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }); - } + LensProtocolRouterMain.getInstance().route(rawUrl); }); /** diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index f06ee014c7..fc2e14a3cb 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -25,7 +25,7 @@ import type { ContextHandler } from "./context-handler"; import { app } from "electron"; import path from "path"; import fs from "fs-extra"; -import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"; +import { dumpConfigYaml } from "../common/kube-helpers"; import logger from "./logger"; import { LensProxy } from "./proxy/lens-proxy"; @@ -86,9 +86,9 @@ export class KubeconfigManager { */ protected async createProxyKubeconfig(): Promise { const { configDir, cluster } = this; - const { contextName, kubeConfigPath, id } = cluster; - const tempFile = path.normalize(path.join(configDir, `kubeconfig-${id}`)); - const kubeConfig = loadConfig(kubeConfigPath); + const { contextName, id } = cluster; + const tempFile = path.join(configDir, `kubeconfig-${id}`); + const kubeConfig = await cluster.getKubeconfig(); const proxyConfig: Partial = { currentContext: contextName, clusters: [ diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 4e5588e47a..25b9e4ca88 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -21,19 +21,23 @@ import type { ClusterId } from "../common/cluster-store"; import { makeObservable, observable } from "mobx"; -import { app, BrowserWindow, dialog, shell, webContents } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/event-bus"; import { subscribeToBroadcast } from "../common/ipc"; import { initMenu } from "./menu"; import { initTray } from "./tray"; -import { Singleton } from "../common/utils"; +import { delay, Singleton } from "../common/utils"; import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import logger from "./logger"; import { productName } from "../common/vars"; import { LensProxy } from "./proxy/lens-proxy"; +function isHideable(window: BrowserWindow | null): boolean { + return Boolean(window && !window.isDestroyed()); +} + export class WindowManager extends Singleton { protected mainWindow: BrowserWindow; protected splashWindow: BrowserWindow; @@ -54,7 +58,7 @@ export class WindowManager extends Singleton { return `http://localhost:${LensProxy.getInstance().port}`; } - async initMainWindow(showSplash = true) { + private async initMainWindow(showSplash: boolean) { // Manage main window size and position with state persistence if (!this.windowState) { this.windowState = windowStateKeeper({ @@ -120,13 +124,8 @@ export class WindowManager extends Singleton { if (showSplash) await this.showSplash(); logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainUrl} ...`); await this.mainWindow.loadURL(this.mainUrl); - this.mainWindow.show(); - this.splashWindow?.close(); - setTimeout(() => { - appEventBus.emit({ name: "app", action: "start" }); - }, 1000); } catch (error) { - logger.error("Showing main window failed", { error }); + logger.error("Loading main window failed", { error }); dialog.showErrorBox("ERROR!", error.toString()); } } @@ -146,9 +145,32 @@ export class WindowManager extends Singleton { }); } - async ensureMainWindow(): Promise { - if (!this.mainWindow) await this.initMainWindow(); - this.mainWindow.show(); + async ensureMainWindow(showSplash = true): Promise { + // This needs to be ready to hear the IPC message before the window is loaded + let viewHasLoaded = Promise.resolve(); + + if (!this.mainWindow) { + viewHasLoaded = new Promise(resolve => { + ipcMain.once(IpcRendererNavigationEvents.LOADED, () => resolve()); + }); + await this.initMainWindow(showSplash); + } + + try { + await viewHasLoaded; + await delay(50); // wait just a bit longer to let the first round of rendering happen + logger.info("[WINDOW-MANAGER]: Main window has reported that it has loaded"); + + this.mainWindow.show(); + this.splashWindow?.close(); + this.splashWindow = undefined; + setTimeout(() => { + appEventBus.emit({ name: "app", action: "start" }); + }, 1000); + } catch (error) { + logger.error(`Showing main window failed: ${error.stack || error}`); + dialog.showErrorBox("ERROR!", error.toString()); + } return this.mainWindow; } @@ -206,8 +228,13 @@ export class WindowManager extends Singleton { } hide() { - if (this.mainWindow && !this.mainWindow.isDestroyed()) this.mainWindow.hide(); - if (this.splashWindow && !this.splashWindow.isDestroyed()) this.splashWindow.hide(); + if (isHideable(this.mainWindow)) { + this.mainWindow.hide(); + } + + if (isHideable(this.splashWindow)) { + this.splashWindow.hide(); + } } destroy() { diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index df51510f23..5339d5232b 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -27,7 +27,7 @@ import { app, remote } from "electron"; import { migration } from "../migration-wrapper"; import fse from "fs-extra"; import { ClusterModel, ClusterStore } from "../../common/cluster-store"; -import { loadConfig } from "../../common/kube-helpers"; +import { loadConfigFromFileSync } from "../../common/kube-helpers"; export default migration({ version: "3.6.0-beta.1", @@ -46,9 +46,13 @@ export default migration({ * migrate kubeconfig */ try { + const absPath = ClusterStore.getCustomKubeConfigPath(cluster.id); + + fse.ensureDirSync(path.dirname(absPath)); + fse.writeFileSync(absPath, cluster.kubeConfig, { encoding: "utf-8", mode: 0o600 }); // take the embedded kubeconfig and dump it into a file - cluster.kubeConfigPath = ClusterStore.embedCustomKubeConfig(cluster.id, cluster.kubeConfig); - cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext(); + cluster.kubeConfigPath = absPath; + cluster.contextName = loadConfigFromFileSync(cluster.kubeConfigPath).config.getCurrentContext(); delete cluster.kubeConfig; } catch (error) { diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index f8ed7129b1..1a0f7e0b17 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -26,8 +26,8 @@ import type { KubeObjectStore } from "../kube-object.store"; import type { ClusterContext } from "../components/context"; import plimit from "p-limit"; -import { comparer, IReactionDisposer, observable, reaction, makeObservable } from "mobx"; -import { autoBind, noop } from "../utils"; +import { comparer, observable, reaction, makeObservable } from "mobx"; +import { autoBind, Disposer, noop } from "../utils"; import type { KubeApi } from "./kube-api"; import type { KubeJsonApiData } from "./kube-json-api"; import { isDebugging, isProduction } from "../../common/vars"; @@ -80,7 +80,7 @@ export class KubeWatchApi { }; } - subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): () => void { + subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): Disposer { const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts; const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? []; const unsubscribeList: Function[] = []; @@ -88,7 +88,7 @@ export class KubeWatchApi { const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce }); let preloading = preload && load(); - let cancelReloading: IReactionDisposer = noop; + let cancelReloading: Disposer = noop; const subscribe = () => { if (isUnsubscribed) return; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 9c91550d47..3479fe0426 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -20,35 +20,48 @@ */ import "./add-cluster.scss"; -import React from "react"; + +import type { KubeConfig } from "@kubernetes/client-node"; +import fse from "fs-extra"; +import { debounce } from "lodash"; +import { action, computed, observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; -import { action, observable, runInAction, makeObservable } from "mobx"; -import { KubeConfig } from "@kubernetes/client-node"; +import path from "path"; +import React from "react"; + +import { catalogURL } from "../+catalog"; +import { ClusterStore } from "../../../common/cluster-store"; +import { appEventBus } from "../../../common/event-bus"; +import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; +import { docsUrl } from "../../../common/vars"; +import { navigate } from "../../navigation"; +import { iter } from "../../utils"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; -import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers"; -import { ClusterStore } from "../../../common/cluster-store"; -import { v4 as uuid } from "uuid"; -import { navigate } from "../../navigation"; -import { UserStore } from "../../../common/user-store"; -import { Notifications } from "../notifications"; -import { ExecValidationNotFoundError } from "../../../common/custom-errors"; -import { appEventBus } from "../../../common/event-bus"; import { PageLayout } from "../layout/page-layout"; -import { docsUrl } from "../../../common/vars"; -import { catalogURL } from "../+catalog"; -import { preferencesURL } from "../+preferences"; -import { Input } from "../input"; +import { Notifications } from "../notifications"; + +interface Option { + config: KubeConfig; + error?: string; +} + +function getContexts(config: KubeConfig): Map { + return new Map( + splitConfig(config) + .map(({ config, error }) => [config.currentContext, { + config, + error, + }]) + ); +} + @observer export class AddCluster extends React.Component { - @observable.ref kubeConfigLocal: KubeConfig; - @observable.ref error: React.ReactNode; + @observable kubeContexts = observable.map(); @observable customConfig = ""; - @observable proxyServer = ""; @observable isWaiting = false; - @observable showSettings = false; - - kubeContexts = observable.map(); + @observable errorText: string; constructor(props: {}) { super(props); @@ -59,159 +72,75 @@ export class AddCluster extends React.Component { appEventBus.emit({ name: "cluster-add", action: "start" }); } - componentWillUnmount() { - UserStore.getInstance().markNewContextsAsSeen(); + @computed get allErrors(): string[] { + return [ + this.errorText, + ...iter.map(this.kubeContexts.values(), ({ error }) => error) + ].filter(Boolean); } @action - refreshContexts() { - this.kubeContexts.clear(); + refreshContexts = debounce(() => { + const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}"); - try { - this.error = ""; - const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); - - console.log(contexts); - - this.kubeContexts.replace(contexts); - } catch (err) { - this.error = String(err); - } - } - - getContexts(config: KubeConfig): Map { - const contexts = new Map(); - - splitConfig(config).forEach(config => { - contexts.set(config.currentContext, config); - }); - - return contexts; - } + this.kubeContexts.replace(getContexts(config)); + this.errorText = error?.toString(); + }, 500); @action - addClusters = (): void => { + addClusters = async () => { + this.isWaiting = true; + appEventBus.emit({ name: "cluster-add", action: "click" }); + try { + const absPath = ClusterStore.getCustomKubeConfigPath(); - this.error = ""; - this.isWaiting = true; - appEventBus.emit({ name: "cluster-add", action: "click" }); - const newClusters = Array.from(this.kubeContexts.keys()).filter(context => { - const kubeConfig = this.kubeContexts.get(context); - const error = validateKubeConfig(kubeConfig, context); + await fse.ensureDir(path.dirname(absPath)); + await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 }); - if (error) { - this.error = error.toString(); + Notifications.ok(`Successfully added ${this.kubeContexts.size} new cluster(s)`); - if (error instanceof ExecValidationNotFoundError) { - Notifications.error(<>Error while adding cluster(s): {this.error}); - } - } - - return Boolean(!error); - }).map(context => { - const clusterId = uuid(); - const kubeConfig = this.kubeContexts.get(context); - const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder - - return { - id: clusterId, - kubeConfigPath, - contextName: kubeConfig.currentContext, - preferences: { - clusterName: kubeConfig.currentContext, - httpsProxy: this.proxyServer || undefined, - }, - }; - }); - - runInAction(() => { - ClusterStore.getInstance().addClusters(...newClusters); - - Notifications.ok( - <>Successfully imported {newClusters.length} cluster(s) - ); - - navigate(catalogURL()); - }); - this.refreshContexts(); - } catch (err) { - this.error = String(err); - Notifications.error(<>Error while adding cluster(s): {this.error}); - } finally { - this.isWaiting = false; + return navigate(catalogURL()); + } catch (error) { + Notifications.error(`Failed to add clusters: ${error}`); } }; - renderInfo() { + render() { return ( -

- Paste kubeconfig as a text from the clipboard to the textarea below. - If you want to add clusters from kubeconfigs that exists on filesystem, please add those files (or folders) to kubeconfig sync via navigate(preferencesURL())}>Preferences. - Read more about adding clusters here. -

- ); - } - - renderKubeConfigSource() { - return ( - <> + +

Add Clusters from Kubeconfig

+

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

{ this.customConfig = value; + this.errorText = ""; this.refreshContexts(); }} />
- - ); - } - - render() { - const submitDisabled = this.kubeContexts.size === 0; - - return ( - -

Add Clusters from Kubeconfig

- {this.renderInfo()} - {this.renderKubeConfigSource()} - - {this.showSettings && ( -
-

HTTP Proxy server. Used for communicating with Kubernetes API.

- this.proxyServer = value} - theme="round-black" - /> - - {"A HTTP proxy server URL (format: http://
:)."} - -
+ {this.allErrors.length > 0 && ( + <> +

KubeConfig Yaml Validation Errors:

+ {...this.allErrors.map(error =>
{error}
)} + )} - {this.error && ( -
{this.error}
- )} -
diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index d8cb43a98d..0bf80577bd 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -30,7 +30,6 @@ import type { HelmChart } from "../../api/endpoints/helm-charts.api"; import { HelmChartDetails } from "./helm-chart-details"; import { navigation } from "../../navigation"; import { ItemListLayout } from "../item-object-list/item-list-layout"; -import { SearchInputUrl } from "../input"; enum columnId { name = "name", @@ -92,9 +91,12 @@ export class HelmCharts extends Component { (chart: HelmChart) => chart.getAppVersion(), (chart: HelmChart) => chart.getKeywords(), ]} - customizeHeader={() => ( - - )} + customizeHeader={({ searchProps }) => ({ + searchProps: { + ...searchProps, + placeholder: "Search Helm Charts...", + }, + })} renderTableHeader={[ { className: "icon", showWithColumn: columnId.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index 7465ad16b5..bb16a4e723 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -34,6 +34,7 @@ import { navigation } from "../../navigation"; import { ItemListLayout } from "../item-object-list/item-list-layout"; import { HelmReleaseMenu } from "./release-menu"; import { secretsStore } from "../+config-secrets/secrets.store"; +import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; enum columnId { name = "name", @@ -116,6 +117,19 @@ export class HelmReleases extends Component { (release: HelmRelease) => release.getStatus(), (release: HelmRelease) => release.getVersion(), ]} + customizeHeader={({ filters, searchProps, ...headerPlaceholders }) => ({ + filters: ( + <> + {filters} + + + ), + searchProps: { + ...searchProps, + placeholder: "Search Releases...", + }, + ...headerPlaceholders, + })} renderHeaderTitle="Releases" renderTableHeader={[ { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, diff --git a/src/renderer/components/+catalog/catalog-entity-details.scss b/src/renderer/components/+catalog/catalog-entity-details.scss new file mode 100644 index 0000000000..f63e7c3ba3 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-details.scss @@ -0,0 +1,43 @@ +/** + * 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. + */ + + .CatalogEntityDetails { + .EntityMetadata { + margin-right: $margin; + } + .EntityIcon.box.top.left { + margin-right: $margin * 2; + + .IconHint { + text-align: center; + font-size: var(--font-size-small); + text-transform: uppercase; + margin-top: $margin; + cursor: default; + user-select: none; + opacity: 0.5; + } + + div * { + font-size: 1.5em; + } + } + } diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx new file mode 100644 index 0000000000..8837920de4 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -0,0 +1,129 @@ +/** + * 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 "./catalog-entity-details.scss"; +import React, { Component } from "react"; +import { observer } from "mobx-react"; +import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer"; +import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogCategory } from "../../../common/catalog"; +import { Icon } from "../icon"; +import { KubeObject } from "../../api/kube-object"; +import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; +import { catalogEntityDetailRegistry } from "../../../extensions/registries"; +import { HotbarIcon } from "../hotbar/hotbar-icon"; + +interface Props { + entity: CatalogEntity; + hideDetails(): void; +} + +@observer +export class CatalogEntityDetails extends Component { + private abortController?: AbortController; + + constructor(props: Props) { + super(props); + } + + componentWillUnmount() { + this.abortController?.abort(); + } + + categoryIcon(category: CatalogCategory) { + if (category.metadata.icon.includes("; + } else { + return ; + } + } + + openEntity() { + this.props.entity.onRun(catalogEntityRunContext); + } + + renderContent() { + const { entity } = this.props; + const labels = KubeObject.stringifyLabels(entity.metadata.labels); + const detailItems = catalogEntityDetailRegistry.getItemsForKind(entity.kind, entity.apiVersion); + const details = detailItems.map((item, index) => { + return ; + }); + + const showDetails = detailItems.find((item) => item.priority > 999) === undefined; + + return ( + <> + {showDetails && ( +
+
+ this.openEntity()} + size={128} /> +
+ Click to open +
+
+
+ + {entity.metadata.name} + + + {entity.kind} + + + {entity.metadata.source} + + +
+
+ )} +
+ {details} +
+ + ); + } + + render() { + const { entity, hideDetails } = this.props; + const title = `${entity.kind}: ${entity.metadata.name}`; + + return ( + } + onClose={hideDetails} + > + {this.renderContent()} + + ); + } +} diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx new file mode 100644 index 0000000000..77c9df5d47 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -0,0 +1,127 @@ +/** + * 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 { cssNames } from "../../utils"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import { observer } from "mobx-react"; +import { makeObservable, observable } from "mobx"; +import { navigate } from "../../navigation"; +import { MenuItem } from "../menu"; +import { ConfirmDialog } from "../confirm-dialog"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { Icon } from "../icon"; + +export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { + entity: T | null | undefined; +} + +@observer +export class CatalogEntityDrawerMenu extends React.Component> { + @observable private contextMenu: CatalogEntityContextMenuContext; + + constructor(props: CatalogEntityDrawerMenuProps) { + super(props); + makeObservable(this); + } + + componentDidMount() { + this.contextMenu = { + menuItems: [], + navigate: (url: string) => navigate(url) + }; + this.props.entity?.onContextMenuOpen(this.contextMenu); + } + + onMenuItemClick(menuItem: CatalogEntityContextMenu) { + if (menuItem.confirm) { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: () => { + menuItem.onClick(); + }, + message: menuItem.confirm.message + }); + } else { + menuItem.onClick(); + } + } + + addToHotbar(entity: CatalogEntity): void { + HotbarStore.getInstance().addToHotbar(entity); + } + + getMenuItems(entity: T): React.ReactChild[] { + if (!entity) { + return []; + } + + const menuItems = this.contextMenu.menuItems.filter((menuItem) => { + return menuItem.icon && !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source; + }); + + const items = menuItems.map((menuItem, index) => { + const props = menuItem.icon.includes(" this.onMenuItemClick(menuItem)}> + + + ); + + }); + + items.unshift( + this.addToHotbar(entity) }> + + + ); + + items.reverse(); + + return items; + } + + render() { + if (!this.contextMenu) { + return null; + } + + const { className, entity, ...menuProps } = this.props; + + return ( + + {this.getMenuItems(entity)} + + ); + } +} diff --git a/src/renderer/components/+catalog/catalog.module.css b/src/renderer/components/+catalog/catalog.module.css new file mode 100644 index 0000000000..937195aa8f --- /dev/null +++ b/src/renderer/components/+catalog/catalog.module.css @@ -0,0 +1,91 @@ +/** + * 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. + */ + +.iconCell { + max-width: 40px; + display: flex; + align-items: center; +} + +.nameCell { +} + +.sourceCell { + max-width: 100px; +} + +.statusCell { + max-width: 100px; +} + +.connected { + color: var(--colorSuccess); +} + +.disconnected { + color: var(--halfGray); +} + +.labelsCell { + overflow-x: scroll; + text-overflow: unset; +} + +.labelsCell::-webkit-scrollbar { + display: none; +} + +.badge { + overflow: unset; + text-overflow: unset; + max-width: unset; +} + +.badge:not(:first-child) { + margin-left: 0.5em; +} + +.catalogIcon { + font-size: 10px; + -webkit-font-smoothing: auto; +} + +.tabs { + @apply flex flex-grow flex-col; +} + +.tab { + @apply px-8 py-4; +} + +.tab:hover { + background-color: var(--sidebarItemHoverBackground); + --color-active: var(--textColorTertiary); +} + +.tab::after { + display: none; +} + +.activeTab, .activeTab:hover { + background-color: var(--blue); + --color-active: white; +} \ No newline at end of file diff --git a/src/renderer/components/+catalog/catalog.scss b/src/renderer/components/+catalog/catalog.scss deleted file mode 100644 index a0afd8c08c..0000000000 --- a/src/renderer/components/+catalog/catalog.scss +++ /dev/null @@ -1,120 +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. - */ - -.CatalogPage { - --width: 100%; - --height: 100%; - --nav-column-width: 200px; - - .sidebarRegion { - justify-content: flex-start; - background-color: var(--sidebarBackground); - - .sidebarHeader { - background: var(--sidebarLogoBackground); - height: var(--main-layout-header); - padding: 4px; - color: var(--textColorAccent); - font-weight: bold; - font-size: 14px; - display: flex; - align-items: center; - padding-left: 10px; - } - - > .sidebar { - width: 100%; - padding: 0; - - .sidebarTabs { - margin-top: 5px; - - .Tab { - padding: 7px 10px; - font-weight: normal; - font-size: 14px; - border-radius: 0; - height: 36px; - - &.active { - background-color: var(--blue); - color: white; - } - } - } - } - } - - .contentRegion { - > .content { - padding: 20px 20px; - } - } - - .TableCell.icon { - max-width: 40px; - display: flex; - align-items: center; - } - - .TableCell.kind { - max-width: 150px; - } - - .TableCell.source { - max-width: 100px; - } - - .TableCell.status { - max-width: 100px; - &.connected { - color: var(--colorSuccess); - } - - &.disconnected { - color: var(--halfGray); - } - } - - .TableCell.labels { - overflow-x: scroll; - text-overflow: unset; - - &::-webkit-scrollbar { - display: none; - } - - .Badge { - overflow: unset; - text-overflow: unset; - max-width: unset; - - &:not(:first-child) { - margin-left: 0.5em; - } - } - } - - .catalogIcon { - font-size: 10px; - -webkit-font-smoothing: auto; - } -} diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 066295b864..2414d90a49 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -19,7 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./catalog.scss"; +import styles from "./catalog.module.css"; + import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; @@ -27,9 +28,8 @@ import { action, makeObservable, observable, reaction, when } from "mobx"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; import { navigate } from "../../navigation"; import { kebabCase } from "lodash"; -import { PageLayout } from "../layout/page-layout"; import { MenuItem, MenuActions } from "../menu"; -import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; @@ -40,6 +40,13 @@ import type { RouteComponentProps } from "react-router"; import type { ICatalogViewRouteParam } from "./catalog.route"; import { Notifications } from "../notifications"; import { Avatar } from "../avatar/avatar"; +import { MainLayout } from "../layout/main-layout"; +import { cssNames } from "../../utils"; +import { TopBar } from "../layout/topbar"; +import { welcomeURL } from "../+welcome"; +import { Icon } from "../icon"; +import { MaterialTooltip } from "../material-tooltip/material-tooltip"; +import { CatalogEntityDetails } from "./catalog-entity-details"; enum sortBy { name = "name", @@ -55,6 +62,7 @@ export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; @observable private contextMenu: CatalogEntityContextMenuContext; @observable activeTab?: string; + @observable selectedItem?: CatalogEntityItem; constructor(props: Props) { super(props); @@ -103,7 +111,7 @@ export class Catalog extends React.Component { } onDetails(item: CatalogEntityItem) { - item.onRun(catalogEntityRunContext); + this.selectedItem = item; } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -137,14 +145,14 @@ export class Catalog extends React.Component { renderNavigation() { return ( - -
Catalog
-
+ +
{ this.categories.map(category => ( @@ -153,6 +161,7 @@ export class Catalog extends React.Component { key={category.getId()} label={category.metadata.name} data-testid={`${category.getId()}-tab`} + className={cssNames(styles.tab, { [styles.activeTab]: this.activeTab == category.getId() })} /> )) } @@ -181,19 +190,13 @@ export class Catalog extends React.Component { }; renderIcon(item: CatalogEntityItem) { - const category = catalogCategoryRegistry.getCategoryForEntity(item.entity); - - if (!category) { - return null; - } - return ( ); } @@ -202,7 +205,6 @@ export class Catalog extends React.Component { return ( { (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ - { title: "", className: "icon" }, - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Source", className: "source", sortBy: sortBy.source }, - { title: "Labels", className: "labels" }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "", className: styles.iconCell }, + { title: "Name", className: styles.nameCell, sortBy: sortBy.name }, + { title: "Source", className: styles.sourceCell, sortBy: sortBy.source }, + { title: "Labels", className: styles.labelsCell }, + { title: "Status", className: styles.statusCell, sortBy: sortBy.status }, ]} renderTableContents={(item: CatalogEntityItem) => [ this.renderIcon(item), @@ -239,7 +241,6 @@ export class Catalog extends React.Component { return ( { (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ - { title: "", className: "icon" }, - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Kind", className: "kind", sortBy: sortBy.kind }, - { title: "Source", className: "source", sortBy: sortBy.source }, - { title: "Labels", className: "labels" }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "", className: styles.iconCell }, + { title: "Name", className: styles.nameCell, sortBy: sortBy.name }, + { title: "Source", className: styles.sourceCell, sortBy: sortBy.source }, + { title: "Labels", className: styles.labelsCell }, + { title: "Status", className: styles.statusCell, sortBy: sortBy.status }, ]} renderTableContents={(item: CatalogEntityItem) => [ this.renderIcon(item), @@ -269,6 +269,7 @@ export class Catalog extends React.Component { item.labels.map((label) => ), { title: item.phase, className: kebabCase(item.phase) } ]} + detailsItem={this.selectedItem} onDetails={(item: CatalogEntityItem) => this.onDetails(item) } renderItemMenu={this.renderItemMenu} /> @@ -281,14 +282,29 @@ export class Catalog extends React.Component { } return ( - - { this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() } - - + <> + +
+ + navigate(welcomeURL())}/> + +
+
+ +
+ { this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() } +
+ { !this.selectedItem && ( + + )} + { this.selectedItem && ( + this.selectedItem = null} + /> + )} +
+ ); } } diff --git a/src/renderer/components/+custom-resources/crd-list.scss b/src/renderer/components/+custom-resources/crd-list.scss index a1f50fb976..eed31b8f7c 100644 --- a/src/renderer/components/+custom-resources/crd-list.scss +++ b/src/renderer/components/+custom-resources/crd-list.scss @@ -42,4 +42,8 @@ } } } -} \ No newline at end of file + + .SearchInput { + width: 300px; + } +} diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 5e44782476..822eff575f 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -95,7 +95,7 @@ export class CrdList extends React.Component { sortingCallbacks={sortingCallbacks} searchFilters={Object.values(sortingCallbacks)} renderHeaderTitle="Custom Resources" - customizeHeader={() => { + customizeHeader={({ filters, ...headerPlaceholders }) => { let placeholder = <>All groups; if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}; @@ -104,26 +104,30 @@ export class CrdList extends React.Component { return { // todo: move to global filters filters: ( - this.toggleSelection(group)} + closeMenuOnSelect={false} + controlShouldRenderValue={false} + formatOptionLabel={({ value: group }: SelectOption) => { + const isSelected = selectedGroups.includes(group); + + return ( +
+ + {group} + {isSelected && } +
+ ); + }} + /> + + ), + ...headerPlaceholders, }; }} renderTableHeader={[ diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index b42703aa33..76ea8607b5 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -101,6 +101,13 @@ export class CrdResources extends React.Component { (item: KubeObject) => item.getSearchFields(), ]} renderHeaderTitle={crd.getResourceTitle()} + customizeHeader={({ searchProps, ...headerPlaceholders }) => ({ + searchProps: { + ...searchProps, + placeholder: `Search ${crd.getResourceTitle()}...`, + }, + ...headerPlaceholders + })} renderTableHeader={[ { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index adf7b2d105..aa0ba475a9 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -30,7 +30,7 @@ import { EventStore, eventStore } from "./event.store"; import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; import type { KubeEvent } from "../../api/endpoints/events.api"; import type { TableSortCallbacks, TableSortParams, TableProps } from "../table"; -import type { IHeaderPlaceholders } from "../item-object-list"; +import type { HeaderCustomizer } from "../item-object-list"; import { Tooltip } from "../tooltip"; import { Link } from "react-router-dom"; import { cssNames, IClassName, stopPropagation } from "../../utils"; @@ -112,19 +112,21 @@ export class Events extends React.Component { return this.items; } - customizeHeader = ({ info, title }: IHeaderPlaceholders) => { + customizeHeader: HeaderCustomizer = ({ info, title, ...headerPlaceholders }) => { const { compact } = this.props; const { store, items, visibleItems } = this; const allEventsAreShown = visibleItems.length === items.length; // handle "compact"-mode header if (compact) { - if (allEventsAreShown) return title; // title == "Events" + if (allEventsAreShown) { + return { title }; + } - return <> - {title} - ({visibleItems.length} of {items.length}) - ; + return { + title, + info: ({visibleItems.length} of {items.length}), + }; } return { @@ -136,7 +138,9 @@ export class Events extends React.Component { className="help-icon" tooltip={`Limited to ${store.limit}`} /> - + , + title, + ...headerPlaceholders }; }; diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 25b14ae9e6..8ae2e97958 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -26,8 +26,6 @@ import { observer } from "mobx-react"; import { components, PlaceholderProps } from "react-select"; import { Icon } from "../icon"; -import { FilterIcon } from "../item-object-list/filter-icon"; -import { FilterType } from "../item-object-list/page-filters.store"; import { NamespaceSelect } from "./namespace-select"; import { namespaceStore } from "./namespace.store"; @@ -63,7 +61,7 @@ export class NamespaceSelectFilter extends React.Component { return (
- + {namespace} {isSelected && }
diff --git a/src/renderer/components/+welcome/welcome.scss b/src/renderer/components/+welcome/welcome.scss index f74e135f70..6141f9f517 100644 --- a/src/renderer/components/+welcome/welcome.scss +++ b/src/renderer/components/+welcome/welcome.scss @@ -22,6 +22,7 @@ .Welcome { text-align: center; width: 100%; + height: 100%; z-index: 1; .box { diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index b4c15b89fd..e1d002c8f7 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -39,7 +39,7 @@ --font-weight-normal: 400; --font-weight-bold: 500; --main-layout-header: 40px; - --drag-region-height: 22px + --drag-region-height: 22px; } *, *:before, *:after { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 5b68b56e2e..cde061b1aa 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -70,6 +70,8 @@ import { CommandContainer } from "./command-palette/command-container"; import { KubeObjectStore } from "../kube-object.store"; import { clusterContext } from "./context"; import { namespaceStore } from "./+namespaces/namespace.store"; +import { Sidebar } from "./layout/sidebar"; +import { Dock } from "./dock"; @observer export class App extends React.Component { @@ -175,7 +177,7 @@ export class App extends React.Component { return ( - + } footer={}> diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index 0c7c450f1b..16f77051c9 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -32,6 +32,7 @@ grid-area: main; position: relative; display: flex; + flex-direction: column; } .HotbarMenu { @@ -45,7 +46,7 @@ #lens-views { position: absolute; left: 0; - top: 0; + top: var(--main-layout-header); // Move below the TopBar right: 0; bottom: 0; display: flex; diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 2d4ccf3a38..c821f2874e 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -19,21 +19,23 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; - import "./cluster-status.scss"; -import React from "react"; -import { observer } from "mobx-react"; + import { ipcRenderer } from "electron"; import { computed, observable, makeObservable } from "mobx"; -import { requestMain, subscribeToBroadcast } from "../../../common/ipc"; -import { Icon } from "../icon"; -import { Button } from "../button"; -import { cssNames, IClassName } from "../../utils"; -import type { Cluster } from "../../../main/cluster"; -import { ClusterId, ClusterStore } from "../../../common/cluster-store"; -import { CubeSpinner } from "../spinner"; +import { observer } from "mobx-react"; +import React from "react"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; +import { ClusterId, ClusterStore } from "../../../common/cluster-store"; +import { requestMain, subscribeToBroadcast } from "../../../common/ipc"; +import type { Cluster } from "../../../main/cluster"; +import { cssNames, IClassName } from "../../utils"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { CubeSpinner } from "../spinner"; +import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; +import { navigate } from "../../navigation"; +import { entitySettingsURL } from "../+entity-settings"; interface Props { className?: IClassName; @@ -82,6 +84,15 @@ export class ClusterStatus extends React.Component { this.isReconnecting = false; }; + manageProxySettings = () => { + navigate(entitySettingsURL({ + params: { + entityId: this.props.clusterId, + }, + fragment: "http-proxy", + })); + }; + renderContent() { const { authOutput, cluster, hasErrors } = this; const failureReason = cluster.failureReason; @@ -89,7 +100,7 @@ export class ClusterStatus extends React.Component { if (!hasErrors || this.isReconnecting) { return ( <> - +
             

{this.isReconnecting ? "Reconnecting..." : "Connecting..."}

{authOutput.map(({ data, error }, index) => { @@ -102,7 +113,7 @@ export class ClusterStatus extends React.Component { return ( <> - +

{cluster.preferences.clusterName}

@@ -121,6 +132,12 @@ export class ClusterStatus extends React.Component { onClick={this.reconnect} waiting={this.isReconnecting} /> +
diff --git a/src/renderer/components/icon/icon.scss b/src/renderer/components/icon/icon.scss index 124e2c1cb9..b10eb3708b 100644 --- a/src/renderer/components/icon/icon.scss +++ b/src/renderer/components/icon/icon.scss @@ -135,7 +135,7 @@ &.interactive { cursor: pointer; transition: 250ms color, 250ms opacity, 150ms background-color, 150ms box-shadow; - border-radius: 50%; + border-radius: var(--border-radius); &.focusable:focus:not(:hover) { box-shadow: 0 0 0 2px var(--focus-color); diff --git a/src/renderer/components/input/search-input-url.tsx b/src/renderer/components/input/search-input-url.tsx index 131f17d383..328f189d6d 100644 --- a/src/renderer/components/input/search-input-url.tsx +++ b/src/renderer/components/input/search-input-url.tsx @@ -32,12 +32,12 @@ export const searchUrlParam = createPageParam({ defaultValue: "", }); -interface Props extends InputProps { +export interface SearchInputUrlProps extends InputProps { compact?: boolean; // show only search-icon when not focused } @observer -export class SearchInputUrl extends React.Component { +export class SearchInputUrl extends React.Component { @observable inputVal = ""; // fix: use empty string on init to avoid react warnings @disposeOnUnmount @@ -62,7 +62,7 @@ export class SearchInputUrl extends React.Component { } }; - constructor(props: Props) { + constructor(props: SearchInputUrlProps) { super(props); makeObservable(this); } diff --git a/src/renderer/components/item-object-list/filter-icon.tsx b/src/renderer/components/item-object-list/filter-icon.tsx index f5534b2f71..87f41c524f 100644 --- a/src/renderer/components/item-object-list/filter-icon.tsx +++ b/src/renderer/components/item-object-list/filter-icon.tsx @@ -31,9 +31,6 @@ export function FilterIcon(props: Props) { const { type, ...iconProps } = props; switch (type) { - case FilterType.NAMESPACE: - return ; - case FilterType.SEARCH: return ; diff --git a/src/renderer/components/item-object-list/item-list-layout.scss b/src/renderer/components/item-object-list/item-list-layout.scss index a91e2a2a18..2609d0b323 100644 --- a/src/renderer/components/item-object-list/item-list-layout.scss +++ b/src/renderer/components/item-object-list/item-list-layout.scss @@ -28,7 +28,7 @@ padding: var(--flex-gap); .title { - color: $textColorPrimary; + color: var(--textColorTertiary); } .info-panel { diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index f894f90a4a..72dafbd90e 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -32,31 +32,30 @@ import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; import type { ItemObject, ItemStore } from "../../item.store"; -import { SearchInputUrl } from "../input"; +import { SearchInputUrlProps, SearchInputUrl } from "../input"; import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { PageFiltersList } from "./page-filters-list"; -import { PageFiltersSelect } from "./page-filters-select"; import { ThemeStore } from "../../theme.store"; import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; import { UserStore } from "../../../common/user-store"; import { namespaceStore } from "../+namespaces/namespace.store"; -import { KubeObjectStore } from "../../kube-object.store"; -import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; -// todo: refactor, split to small re-usable components + export type SearchFilter = (item: T) => string | number | (string | number)[]; export type ItemsFilter = (items: T[]) => T[]; -export interface IHeaderPlaceholders { - title: ReactNode; - search: ReactNode; - filters: ReactNode; - info: ReactNode; +export interface HeaderPlaceholders { + title?: ReactNode; + searchProps?: SearchInputUrlProps; + filters?: ReactNode; + info?: ReactNode; } +export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; + export interface ItemListLayoutProps { tableId?: string; className: IClassName; @@ -73,12 +72,11 @@ export interface ItemListLayoutProps { showHeader?: boolean; headerClassName?: IClassName; renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode); - customizeHeader?: (placeholders: IHeaderPlaceholders, content: ReactNode) => Partial | ReactNode; + customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; // items list configuration isReady?: boolean; // show loading indicator while not ready isSelectable?: boolean; // show checkbox in rows for selecting items - isSearchable?: boolean; // apply search-filter & add search-input isConfigurable?: boolean; copyClassNameFromHeadCells?: boolean; sortingCallbacks?: { [sortBy: string]: TableSortCallback }; @@ -102,12 +100,13 @@ export interface ItemListLayoutProps { const defaultProps: Partial = { showHeader: true, - isSearchable: true, isSelectable: true, isConfigurable: false, copyClassNameFromHeadCells: true, preloadStores: true, dependentStores: [], + searchFilters: [], + customizeHeader: [], filterItems: [], hasDetailsView: true, onDetails: noop, @@ -161,10 +160,10 @@ export class ItemListLayout extends React.Component { private filterCallbacks: { [type: string]: ItemsFilter } = { [FilterType.SEARCH]: items => { - const { searchFilters, isSearchable } = this.props; + const { searchFilters } = this.props; const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; - if (search && isSearchable && searchFilters) { + if (search && searchFilters.length) { const normalizeText = (text: string) => String(text).toLowerCase(); const searchTexts = [search].map(normalizeText); @@ -179,16 +178,6 @@ export class ItemListLayout extends React.Component { return items; }, - - [FilterType.NAMESPACE]: items => { - const filterValues = pageFilters.getValues(FilterType.NAMESPACE); - - if (filterValues.length > 0) { - return items.filter(item => filterValues.includes(item.getNs())); - } - - return items; - }, }; @computed get isReady() { @@ -201,9 +190,9 @@ export class ItemListLayout extends React.Component { @computed get filters() { let { activeFilters } = pageFilters; - const { isSearchable, searchFilters } = this.props; + const { searchFilters } = this.props; - if (!(isSearchable && searchFilters)) { + if (searchFilters.length === 0) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); } @@ -359,18 +348,22 @@ export class ItemListLayout extends React.Component { return this.items.map(item => this.getRow(item.getId())); } - renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { - const { isSearchable, searchFilters } = this.props; - const { title, filters, search, info } = placeholders; + renderHeaderContent(placeholders: HeaderPlaceholders): ReactNode { + const { searchFilters } = this.props; + const { title, filters, searchProps, info } = placeholders; return ( <> {title} -
- {info} -
+ { + info && ( +
+ {info} +
+ ) + } {filters} - {isSearchable && searchFilters && search} + {searchFilters.length > 0 && searchProps && } ); } @@ -396,35 +389,15 @@ export class ItemListLayout extends React.Component { return null; } - const showNamespaceSelectFilter = this.props.store instanceof KubeObjectStore && this.props.store.api.isNamespaced; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; - const placeholders: IHeaderPlaceholders = { + const customizeHeaders = [customizeHeader].flat().filter(Boolean); + const initialPlaceholders: HeaderPlaceholders = { title:
{title}
, info: this.renderInfo(), - filters: ( - <> - {showNamespaceSelectFilter && } - - - ), - search: , + searchProps: {}, }; - let header = this.renderHeaderContent(placeholders); - - if (customizeHeader) { - const modifiedHeader = customizeHeader(placeholders, header) ?? {}; - - if (isReactNode(modifiedHeader)) { - header = modifiedHeader; - } else { - header = this.renderHeaderContent({ - ...placeholders, - ...modifiedHeader as IHeaderPlaceholders, - }); - } - } + const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders); + const header = this.renderHeaderContent(headerPlaceholders); return (
diff --git a/src/renderer/components/item-object-list/page-filters-select.tsx b/src/renderer/components/item-object-list/page-filters-select.tsx deleted file mode 100644 index 418c82045b..0000000000 --- a/src/renderer/components/item-object-list/page-filters-select.tsx +++ /dev/null @@ -1,136 +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 React from "react"; -import { observer } from "mobx-react"; -import { computed, makeObservable } from "mobx"; -import { GroupSelectOption, Select, SelectOption, SelectProps } from "../select"; -import { FilterType, pageFilters } from "./page-filters.store"; -import { namespaceStore } from "../+namespaces/namespace.store"; -import { Icon } from "../icon"; -import { FilterIcon } from "./filter-icon"; - -export interface SelectOptionFilter extends SelectOption { - type: FilterType; - selected?: boolean; -} - -interface Props extends SelectProps { - allowEmpty?: boolean; - disableFilters?: { - [filterType: string]: boolean; - }; -} - -@observer -export class PageFiltersSelect extends React.Component { - static defaultProps: Props = { - allowEmpty: true, - disableFilters: {}, - }; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - @computed get groupedOptions() { - const options: GroupSelectOption[] = []; - const { disableFilters } = this.props; - - if (!disableFilters[FilterType.NAMESPACE]) { - const selectedValues = pageFilters.getValues(FilterType.NAMESPACE); - - options.push({ - label: "Namespace", - options: namespaceStore.items.map(ns => { - const name = ns.getName(); - - return { - type: FilterType.NAMESPACE, - value: name, - icon: , - selected: selectedValues.includes(name), - }; - }) - }); - } - - return options; - } - - @computed get options(): SelectOptionFilter[] { - return this.groupedOptions.reduce((options, optGroup) => { - options.push(...optGroup.options); - - return options; - }, []); - } - - private formatLabel = (option: SelectOptionFilter) => { - const { label, value, type, selected } = option; - - return ( -
- - {label || String(value)} - {selected && } -
- ); - }; - - private onSelect = (option: SelectOptionFilter) => { - const { type, value, selected } = option; - const { addFilter, removeFilter } = pageFilters; - const filter = { type, value }; - - if (!selected) { - addFilter(filter); - } - else { - removeFilter(filter); - } - }; - - render() { - const { groupedOptions, formatLabel, onSelect, options } = this; - - if (!options.length && this.props.allowEmpty) { - return null; - } - const { allowEmpty, disableFilters, ...selectProps } = this.props; - const selectedOptions = options.filter(opt => opt.selected); - - return ( -