diff --git a/.github/workflows/publish-master-npm.yml b/.github/workflows/publish-master-npm.yml index c6c4284e6a..2be9721d3e 100644 --- a/.github/workflows/publish-master-npm.yml +++ b/.github/workflows/publish-master-npm.yml @@ -1,10 +1,8 @@ name: Publish NPM Package `master` on: - pull_request: + push: branches: - master - types: - - closed jobs: publish: name: Publish NPM Package `master` diff --git a/Makefile b/Makefile index 3139b077b4..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,7 @@ 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 --tag=${NPM_RELEASE_TAG:-latest} + cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG) git restore src/extensions/npm/extensions/package.json .PHONY: docs 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/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/package.json b/package.json index 4400cf8d72..22170a5842 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", @@ -227,7 +228,7 @@ "react-router": "^5.2.0", "readable-stream": "^3.6.0", "request": "^2.88.2", - "request-promise-native": "^1.0.8", + "request-promise-native": "^1.0.9", "semver": "^7.3.2", "serializr": "^2.0.3", "shell-env": "^3.0.1", @@ -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/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 b1980d3f79..cc33ab332e 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 "./escapeRegExp"; export * from "./extended-map"; export * from "./getRandId"; 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/src/common/utils/saveToAppFiles.ts b/src/common/utils/paths.ts similarity index 68% rename from src/common/utils/saveToAppFiles.ts rename to src/common/utils/paths.ts index b957cdb38e..391e1392f2 100644 --- a/src/common/utils/saveToAppFiles.ts +++ b/src/common/utils/paths.ts @@ -19,17 +19,17 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Save file to electron app directory (e.g. "/Users/$USER/Library/Application Support/Lens" for MacOS) import path from "path"; -import { app, remote } from "electron"; -import { ensureDirSync, writeFileSync } from "fs-extra"; -import type { WriteFileOptions } from "fs"; +import os from "os"; -export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { - const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); +function resolveTilde(filePath: string) { + if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { + return filePath.replace("~", os.homedir()); + } - ensureDirSync(path.dirname(absPath)); - writeFileSync(absPath, contents, options); - - return absPath; + 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 431b6de4d7..a49c8da910 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -68,10 +68,5 @@ export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as stri 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 export const appSemVer = new SemVer(packageInfo.version); -const { major, minor, patch } = appSemVer; -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 ec42818064..af85d11e7f 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"; @@ -296,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); @@ -311,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 7b927def01..ac77a3b229 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -20,12 +20,12 @@ */ 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 type { PackageJson } from "type-fest"; +import { Disposer, disposer } from "../common/utils"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -82,59 +82,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/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 bdc8cf22b6..bb16a4e723 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -117,16 +117,20 @@ export class HelmReleases extends Component { (release: HelmRelease) => release.getStatus(), (release: HelmRelease) => release.getVersion(), ]} - renderHeaderTitle="Releases" - customizeHeader={({ filters, ...headerPlaceholders }) => ({ + 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 }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, 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 2059658d1f..2414d90a49 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -205,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), @@ -242,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), 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/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} /> +