diff --git a/Makefile b/Makefile index e548232aa9..dfeb43fe0e 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,10 @@ dev: binaries/client build-extensions static/build/LensDev.html lint: yarn lint +.PHONY: release-version +release-version: + npm version $(CMD_ARGS) --git-tag-version false + .PHONY: test test: binaries/client yarn run jest $(or $(CMD_ARGS), "src") diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index da42b474cb..edbe3127d1 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -53,6 +53,9 @@ export async function addMinikubeCluster(app: Application) { await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) await app.client.click("button.primary"); // add minikube cluster await app.client.waitUntilTextExists("div.TableCell", "minikube"); + await app.client.waitForExist(".Input.SearchInput input"); + await app.client.setValue(".Input.SearchInput input", "minikube"); + await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); } diff --git a/package.json b/package.json index 94dff13821..048b8614a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-lens", "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", - "version": "5.0.0-alpha.4", + "version": "5.0.0-beta.2", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", @@ -39,7 +39,11 @@ "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" + "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", + "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", + "postversion": "git push --set-upstream origin release/v$npm_package_version" }, "config": { "bundledKubectlVersion": "1.18.15", @@ -233,6 +237,7 @@ "devDependencies": { "@emeraldpay/hashicon-react": "^0.4.0", "@material-ui/core": "^4.10.1", + "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.57", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@testing-library/jest-dom": "^5.11.10", @@ -250,7 +255,7 @@ "@types/hoist-non-react-statics": "^3.3.1", "@types/html-webpack-plugin": "^3.2.3", "@types/http-proxy": "^1.17.5", - "@types/jest": "^25.2.3", + "@types/jest": "^26.0.22", "@types/js-yaml": "^3.12.4", "@types/jsdom": "^16.2.4", "@types/jsonpath": "^0.2.0", @@ -340,7 +345,7 @@ "sharp": "^0.26.1", "spectron": "11.0.0", "style-loader": "^1.2.1", - "ts-jest": "^26.1.0", + "ts-jest": "26.3.0", "ts-loader": "^7.0.5", "ts-node": "^8.10.2", "type-fest": "^1.0.2", diff --git a/src/common/__tests__/catalog-entity-registry.test.ts b/src/common/__tests__/catalog-entity-registry.test.ts index 1a53865241..50bdaa0b1e 100644 --- a/src/common/__tests__/catalog-entity-registry.test.ts +++ b/src/common/__tests__/catalog-entity-registry.test.ts @@ -27,7 +27,7 @@ describe("CatalogEntityRegistry", () => { it ("allows to add an observable source", () => { const source = observable.array([]); - registry.addSource("test", source); + registry.addObservableSource("test", source); expect(registry.items.length).toEqual(0); source.push(entity); @@ -38,7 +38,7 @@ describe("CatalogEntityRegistry", () => { it ("added source change triggers reaction", (done) => { const source = observable.array([]); - registry.addSource("test", source); + registry.addObservableSource("test", source); reaction(() => registry.items, () => { done(); }); @@ -51,7 +51,7 @@ describe("CatalogEntityRegistry", () => { it ("removes source", () => { const source = observable.array([]); - registry.addSource("test", source); + registry.addObservableSource("test", source); source.push(entity); registry.removeSource("test"); diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts index ddecc6bde8..808092c89d 100644 --- a/src/common/__tests__/kube-helpers.test.ts +++ b/src/common/__tests__/kube-helpers.test.ts @@ -1,5 +1,5 @@ import { KubeConfig } from "@kubernetes/client-node"; -import { validateKubeConfig, loadConfig } from "../kube-helpers"; +import { validateKubeConfig, loadConfig, getNodeWarningConditions } from "../kube-helpers"; const kubeconfig = ` apiVersion: v1 @@ -72,55 +72,63 @@ describe("kube helpers", () => { }); describe("with default validation options", () => { describe("with valid kubeconfig", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "valid")).toBeUndefined(); }); }); describe("with invalid context object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalid"))).toEqual( + expect.stringContaining("No valid context object provided in kubeconfig for context 'invalid'") + ); }); }); describe("with invalid cluster object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalidCluster"))).toEqual( + expect.stringContaining("No valid cluster object provided in kubeconfig for context 'invalidCluster'") + ); }); }); describe("with invalid user object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalidUser"))).toEqual( + expect.stringContaining("No valid user object provided in kubeconfig for context 'invalidUser'") + ); }); }); describe("with invalid exec command", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalidExec"))).toEqual( + expect.stringContaining("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig") + ); }); }); }); describe("with validateCluster as false", () => { describe("with invalid cluster object", () => { - it("does not raise exception", () => { - expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "invalidCluster", { validateCluster: false })).toBeUndefined(); }); }); }); describe("with validateUser as false", () => { describe("with invalid user object", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "invalidUser", { validateUser: false })).toBeUndefined(); }); }); }); describe("with validateExec as false", () => { describe("with invalid exec object", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "invalidExec", { validateExec: false })).toBeUndefined(); }); }); }); @@ -251,4 +259,43 @@ describe("kube helpers", () => { }); }); }); + + describe("getNodeWarningConditions", () => { + it("should return an empty array if no status or no conditions", () => { + expect(getNodeWarningConditions({}).length).toBe(0); + }); + + it("should return an empty array if all conditions are good", () => { + expect(getNodeWarningConditions({ + status: { + conditions: [ + { + type: "Ready", + status: "foobar" + } + ] + } + }).length).toBe(0); + }); + + it("should all not ready conditions", () => { + const conds = getNodeWarningConditions({ + status: { + conditions: [ + { + type: "Ready", + status: "foobar" + }, + { + type: "NotReady", + status: "true" + }, + ] + } + }); + + expect(conds.length).toBe(1); + expect(conds[0].type).toBe("NotReady"); + }); + }); }); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 7e007efb0d..01edbda09d 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -19,12 +19,13 @@ import { UserStore } from "../user-store"; import { SemVer } from "semver"; import electron from "electron"; import { stdout, stderr } from "process"; +import { beforeEachWrapped } from "../../../integration/helpers/utils"; console = new Console(stdout, stderr); describe("user store tests", () => { describe("for an empty config", () => { - beforeEach(() => { + beforeEachWrapped(() => { UserStore.resetInstance(); mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" } }); @@ -90,7 +91,7 @@ describe("user store tests", () => { }); describe("migrations", () => { - beforeEach(() => { + beforeEachWrapped(() => { UserStore.resetInstance(); mockFs({ "tmp": { diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index c2dae01b74..dc5159e509 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -94,7 +94,7 @@ export class KubernetesClusterCategory extends CatalogCategory { ctx.menuItems.push({ icon: "text_snippet", title: "Add from kubeconfig", - onClick: async () => { + onClick: () => { ctx.navigate("/add-cluster"); } }); diff --git a/src/common/catalog/catalog-entity-registry.ts b/src/common/catalog/catalog-entity-registry.ts index 09b72c3430..f749e2fe90 100644 --- a/src/common/catalog/catalog-entity-registry.ts +++ b/src/common/catalog/catalog-entity-registry.ts @@ -1,15 +1,19 @@ -import { action, computed, IObservableArray, makeObservable, observable } from "mobx"; +import { action, computed, observable, IComputedValue, IObservableArray, makeObservable } from "mobx"; import { CatalogEntity } from "./catalog-entity"; -import { toJS } from "../utils"; +import { iter } from "../utils"; export class CatalogEntityRegistry { - protected sources = observable.map>([], { deep: true }); + protected sources = observable.map>([], { deep: true }); constructor() { makeObservable(this); } - @action addSource(id: string, source: IObservableArray) { + @action addObservableSource(id: string, source: IObservableArray) { + this.sources.set(id, computed(() => source)); + } + + @action addComputedSource(id: string, source: IComputedValue) { this.sources.set(id, source); } @@ -18,7 +22,7 @@ export class CatalogEntityRegistry { } @computed get items(): CatalogEntity[] { - return toJS(Array.from(this.sources.values()).flat()); + return Array.from(iter.flatMap(this.sources.values(), source => source.get())); } getItemsForApiKind(apiVersion: string, kind: string): T[] { diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 4e731da1fb..ecaba3a9d2 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -65,7 +65,7 @@ export interface CatalogEntityContextMenu { icon: string; title: string; onlyVisibleForSource?: string; // show only if empty or if matches with entity source - onClick: () => Promise; + onClick: () => void | Promise; confirm?: { message: string; } diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index d7864f8b5d..c701335834 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -37,6 +37,10 @@ export interface ClusterStoreModel { export type ClusterId = string; +export interface UpdateClusterModel extends Omit { + id?: ClusterId; +} + export interface ClusterModel { /** Unique id for a cluster */ id: ClusterId; @@ -94,8 +98,12 @@ export interface ClusterPrometheusPreferences { } export class ClusterStore extends BaseStore { + static get storedKubeConfigFolder(): string { + return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); + } + static getCustomKubeConfigPath(clusterId: ClusterId): string { - return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs", clusterId); + return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); } static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { @@ -261,18 +269,18 @@ export class ClusterStore extends BaseStore { } @action - addCluster(model: ClusterModel | Cluster): Cluster { + addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); - let cluster = model as Cluster; - if (!(model instanceof Cluster)) { - cluster = new Cluster(model); - } + const cluster = clusterOrModel instanceof Cluster + ? clusterOrModel + : new Cluster(clusterOrModel); if (!cluster.isManaged) { cluster.enabled = true; } - this.clusters.set(model.id, cluster); + + this.clusters.set(cluster.id, cluster); return cluster; } diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index e500179940..23247b3200 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -161,18 +161,6 @@ export class HotbarStore extends BaseStore { hotbar.items[index] = null; } - addEmptyCell() { - const hotbar = this.getActive(); - - hotbar.items.push(null); - } - - removeEmptyCell(index: number) { - const hotbar = this.getActive(); - - hotbar.items.splice(index, 1); - } - switchToPrevious() { const hotbarStore = HotbarStore.getInstance(); let index = hotbarStore.activeHotbarIndex - 1; diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index fb4691fedb..35d32a3d8f 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -6,7 +6,7 @@ import yaml from "js-yaml"; import logger from "../main/logger"; import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; -import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types"; +import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; export type KubeConfigValidationOpts = { validateCluster?: boolean; @@ -28,11 +28,26 @@ function readResolvedPathSync(filePath: string): string { return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8"); } -function checkRawContext(rawContext: any): boolean { - return rawContext.name && rawContext.context?.cluster && rawContext.context?.user; +function checkRawCluster(rawCluster: any): boolean { + return Boolean(rawCluster?.name && rawCluster?.cluster?.server); } -function loadToOptions(rawYaml: string): any { +function checkRawUser(rawUser: any): boolean { + return Boolean(rawUser?.name); +} + +function checkRawContext(rawContext: any): boolean { + return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user); +} + +export interface KubeConfigOptions { + clusters: Cluster[]; + users: User[]; + contexts: Context[]; + currentContext: string; +} + +function loadToOptions(rawYaml: string): KubeConfigOptions { const obj = yaml.safeLoad(rawYaml); if (typeof obj !== "object" || !obj) { @@ -40,16 +55,14 @@ function loadToOptions(rawYaml: string): any { } const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj; - const clusters = newClusters(rawClusters); - const users = newUsers(rawUsers); + const clusters = newClusters(rawClusters?.filter(checkRawCluster)); + const users = newUsers(rawUsers?.filter(checkRawUser)); const contexts = newContexts(rawContexts?.filter(checkRawContext)); return { clusters, users, contexts, currentContext }; } -export function loadConfig(pathOrContent?: string): KubeConfig { - const content = fse.pathExistsSync(pathOrContent) ? readResolvedPathSync(pathOrContent) : pathOrContent; - const options = loadToOptions(content); +export function loadFromOptions(options: KubeConfigOptions): KubeConfig { const kc = new KubeConfig(); // need to load using the kubernetes client to generate a kubeconfig object @@ -58,6 +71,18 @@ export function loadConfig(pathOrContent?: string): KubeConfig { return kc; } +export function loadConfig(pathOrContent?: string): KubeConfig { + return loadConfigFromString( + fse.pathExistsSync(pathOrContent) + ? readResolvedPathSync(pathOrContent) + : pathOrContent + ); +} + +export function loadConfigFromString(content: string): KubeConfig { + return loadFromOptions(loadToOptions(content)); +} + /** * KubeConfig is valid when there's at least one of each defined: * - User @@ -174,49 +199,54 @@ export function podHasIssues(pod: V1Pod) { } export function getNodeWarningConditions(node: V1Node) { - return node.status.conditions.filter(c => + return node.status?.conditions?.filter(c => c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades" - ); + ) ?? []; } /** * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) + * + * 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 = {}) { - // 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 +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 - const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts; + const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts; - const contextObject = config.getContextObject(contextName); + const contextObject = config.getContextObject(contextName); - // Validate the Context Object - if (!contextObject) { - throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); - } - - // Validate the Cluster Object - if (validateCluster && !config.getCluster(contextObject.cluster)) { - throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`); - } - - const user = config.getUser(contextObject.user); - - // Validate the User Object - if (validateUser && !user) { - throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); - } - - // Validate exec command if present - if (validateExec && user?.exec) { - const execCommand = user.exec["command"]; - // check if the command is absolute or not - const isAbsolute = path.isAbsolute(execCommand); - - // validate the exec struct in the user object, start with the command field - if (!commandExists.sync(execCommand)) { - logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`); - throw new ExecValidationNotFoundError(execCommand, isAbsolute); + // Validate the Context Object + if (!contextObject) { + return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); } + + // Validate the Cluster Object + if (validateCluster && !config.getCluster(contextObject.cluster)) { + return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`); + } + + const user = config.getUser(contextObject.user); + + // Validate the User Object + if (validateUser && !user) { + return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); + } + + // Validate exec command if present + if (validateExec && user?.exec) { + const execCommand = user.exec["command"]; + // check if the command is absolute or not + const isAbsolute = path.isAbsolute(execCommand); + + // validate the exec struct in the user object, start with the command field + if (!commandExists.sync(execCommand)) { + return new ExecValidationNotFoundError(execCommand, isAbsolute); + } + } + } catch (error) { + return error; } } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 4ffed5a32e..5e21c6da82 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -11,6 +11,7 @@ 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 } from "../renderer/utils"; @@ -21,6 +22,12 @@ export interface UserStoreModel { preferences: UserPreferencesModel; } +export interface KubeconfigSyncEntry extends KubeconfigSyncValue { + filePath: string; +} + +export interface KubeconfigSyncValue {} + export interface UserPreferencesModel { httpsProxy?: string; shell?: string; @@ -34,6 +41,7 @@ export interface UserPreferencesModel { kubectlBinariesPath?: string; openAtLogin?: boolean; hiddenTableColumns?: [string, string[]][]; + syncKubeconfigEntries?: KubeconfigSyncEntry[]; } export class UserStore extends BaseStore { @@ -44,9 +52,7 @@ export class UserStore extends BaseStore { configName: "lens-user-store", migrations, }); - makeObservable(this); - this.handleOnLoad(); } @observable lastSeenAppVersion = "0.0.0"; @@ -72,14 +78,31 @@ export class UserStore extends BaseStore { */ @observable downloadKubectlBinaries = true; @observable openAtLogin = false; + + /** + * The column IDs under each configurable table ID that have been configured + * to not be shown + */ hiddenTableColumns = observable.map>(); - protected async handleOnLoad() { - await this.whenLoaded; + /** + * The set of file/folder paths to be synced + */ + syncKubeconfigEntries = observable.map([ + [path.join(os.homedir(), ".kube"), {}] + ]); + + async load(): Promise { + /** + * This has to be here before the call to `new Config` in `super.load()` + * as we have to make sure that file is in the expected place for that call + */ + await fileNameMigration(); + await super.load(); // refresh new contexts - this.refreshNewContexts(); - reaction(() => this.kubeConfigPath, this.refreshNewContexts); + await this.refreshNewContexts(); + reaction(() => this.kubeConfigPath, () => this.refreshNewContexts()); if (app) { // track telemetry availability @@ -100,16 +123,6 @@ export class UserStore extends BaseStore { } } - async load(): Promise { - /** - * This has to be here before the call to `new Config` in `super.load()` - * as we have to make sure that file is in the expected place for that call - */ - await fileNameMigration(); - - return super.load(); - } - @computed get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); } @@ -172,7 +185,7 @@ export class UserStore extends BaseStore { this.localeTimezone = tz; } - protected refreshNewContexts = async () => { + protected async refreshNewContexts() { try { const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); @@ -187,7 +200,7 @@ export class UserStore extends BaseStore { logger.error(err); this.resetKubeConfigPath(); } - }; + } @action markNewContextsAsSeen() { @@ -226,37 +239,50 @@ export class UserStore extends BaseStore { this.kubectlBinariesPath = preferences.kubectlBinariesPath; this.openAtLogin = preferences.openAtLogin; - this.hiddenTableColumns.clear(); + if (preferences.hiddenTableColumns) { + this.hiddenTableColumns.replace( + preferences.hiddenTableColumns + .map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)]) + ); + } - for (const [tableId, columnIds] of preferences.hiddenTableColumns ?? []) { - this.hiddenTableColumns.set(tableId, new ObservableToggleSet(columnIds)); + if (preferences.syncKubeconfigEntries) { + this.syncKubeconfigEntries.replace( + preferences.syncKubeconfigEntries.map(({ filePath, ...rest }) => [filePath, rest]) + ); } } toJSON(): UserStoreModel { const hiddenTableColumns: [string, string[]][] = []; + const syncKubeconfigEntries: KubeconfigSyncEntry[] = []; for (const [key, values] of this.hiddenTableColumns.entries()) { hiddenTableColumns.push([key, Array.from(values)]); } + for (const [filePath, rest] of this.syncKubeconfigEntries) { + syncKubeconfigEntries.push({ filePath, ...rest }); + } + const model: UserStoreModel = { kubeConfigPath: this.kubeConfigPath, lastSeenAppVersion: this.lastSeenAppVersion, seenContexts: Array.from(this.seenContexts), preferences: { - httpsProxy: this.httpsProxy, - shell: this.shell, - colorTheme: this.colorTheme, - localeTimezone: this.localeTimezone, - allowUntrustedCAs: this.allowUntrustedCAs, - allowTelemetry: this.allowTelemetry, - downloadMirror: this.downloadMirror, - downloadKubectlBinaries: this.downloadKubectlBinaries, - downloadBinariesPath: this.downloadBinariesPath, - kubectlBinariesPath: this.kubectlBinariesPath, - openAtLogin: this.openAtLogin, + httpsProxy: toJS(this.httpsProxy), + shell: toJS(this.shell), + colorTheme: toJS(this.colorTheme), + localeTimezone: toJS(this.localeTimezone), + allowUntrustedCAs: toJS(this.allowUntrustedCAs), + allowTelemetry: toJS(this.allowTelemetry), + downloadMirror: toJS(this.downloadMirror), + downloadKubectlBinaries: toJS(this.downloadKubectlBinaries), + downloadBinariesPath: toJS(this.downloadBinariesPath), + kubectlBinariesPath: toJS(this.kubectlBinariesPath), + openAtLogin: toJS(this.openAtLogin), hiddenTableColumns, + syncKubeconfigEntries, }, }; diff --git a/src/common/utils/extended-map.ts b/src/common/utils/extended-map.ts new file mode 100644 index 0000000000..24fa49b696 --- /dev/null +++ b/src/common/utils/extended-map.ts @@ -0,0 +1,64 @@ +import { action, IEnhancer, IObservableMapInitialValues, ObservableMap } from "mobx"; + +export class ExtendedMap extends Map { + constructor(protected getDefault: () => V, entries?: readonly (readonly [K, V])[] | null) { + super(entries); + } + + getOrInsert(key: K, val: V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, val).get(key); + } + + getOrInsertWith(key: K, getVal: () => V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, getVal()).get(key); + } + + getOrDefault(key: K): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, this.getDefault()).get(key); + } +} + +export class ExtendedObservableMap extends ObservableMap { + constructor(protected getDefault: () => V, initialData?: IObservableMapInitialValues, enhancer?: IEnhancer, name?: string) { + super(initialData, enhancer, name); + } + + @action + getOrInsert(key: K, val: V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, val).get(key); + } + + @action + getOrInsertWith(key: K, getVal: () => V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, getVal()).get(key); + } + + @action + getOrDefault(key: K): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, this.getDefault()).get(key); + } +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 01b14353ca..57dd6e1cbc 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -12,10 +12,13 @@ export * from "./debouncePromise"; export * from "./defineGlobal"; export * from "./delay"; export * from "./disposer"; +export * from "./disposer"; export * from "./downloadFile"; export * from "./escapeRegExp"; +export * from "./extended-map"; export * from "./getRandId"; export * from "./openExternal"; +export * from "./reject-promise"; export * from "./saveToAppFiles"; export * from "./singleton"; export * from "./splitArray"; diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index f364ce4aee..e65556fcf0 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -23,3 +23,67 @@ export function* take(src: Iterable, n: number): Iterable { break outer; } } + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` for each item. + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* map(src: Iterable, fn: (from: T) => U): Iterable { + for (const from of src) { + yield fn(from); + } +} + +export function* flatMap(src: Iterable, fn: (from: T) => Iterable): Iterable { + for (const from of src) { + yield* fn(from); + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * items that return a `truthy` value from `fn`. + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filter(src: Iterable, fn: (from: T) => any): Iterable { + for (const from of src) { + if (fn(from)) { + yield from; + } + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` when it is `truthy` + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filterMap(src: Iterable, fn: (from: T) => U): Iterable { + for (const from of src) { + const res = fn(from); + + if (res) { + yield res; + } + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` when it is not null or undefined + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filterMapStrict(src: Iterable, fn: (from: T) => U): Iterable { + for (const from of src) { + const res = fn(from); + + if (res != null) { + yield res; + } + } +} diff --git a/src/common/utils/reject-promise.ts b/src/common/utils/reject-promise.ts new file mode 100644 index 0000000000..a263ce4489 --- /dev/null +++ b/src/common/utils/reject-promise.ts @@ -0,0 +1,13 @@ +import "abort-controller/polyfill"; + +/** + * Creates a new promise that will be rejected when the signal rejects. + * + * Useful for `Promise.race()` applications. + * @param signal The AbortController's signal to reject with + */ +export function rejectPromiseBy(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener("abort", reject); + }); +} diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index d6e43942e4..754c333d80 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -42,6 +42,10 @@ interface ExtensionDiscoveryChannelMessage { */ const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); +interface LoadFromFolderOptions { + isBundled?: boolean; +} + /** * Discovers installed bundled and local extensions from the filesystem. * Also watches for added and removed local extensions by watching the directory. @@ -332,7 +336,12 @@ export class ExtensionDiscovery extends Singleton { isEnabled }; } catch (error) { - logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`); + if (error.code === "ENOTDIR") { + // ignore this error, probably from .DS_Store file + logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`); + } else { + logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`); + } return null; } @@ -429,12 +438,10 @@ export class ExtensionDiscovery extends Singleton { /** * Loads extension from absolute path, updates this.packagesJson to include it and returns the extension. - * @param absPath Folder path to extension + * @param folderPath Folder path to extension */ - async loadExtensionFromFolder(absPath: string, { isBundled = false }: { - isBundled?: boolean; - } = {}): Promise { - const manifestPath = path.resolve(absPath, manifestFilename); + async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise { + const manifestPath = path.resolve(folderPath, manifestFilename); return this.getByManifest(manifestPath, { isBundled }); } diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index d01243aac1..d2bab0ba54 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -20,7 +20,7 @@ export class LensMainExtension extends LensExtension { } addCatalogSource(id: string, source: IObservableArray) { - catalogEntityRegistry.addSource(`${this.name}:${id}`, source); + catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source); } removeCatalogSource(id: string) { diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index 90530c71ea..84ac8dc32d 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -1,7 +1,7 @@ -import { autorun } from "mobx"; -import { toJS } from "../common/utils"; +import { reaction } from "mobx"; +import { toJS, Disposer } from "../common/utils"; import { broadcastMessage, subscribeToBroadcast, unsubscribeFromBroadcast } from "../common/ipc"; -import { CatalogEntityRegistry } from "../common/catalog"; +import { CatalogEntityRegistry} from "../common/catalog"; import "../common/catalog-entities/kubernetes-cluster"; export class CatalogPusher { @@ -9,26 +9,23 @@ export class CatalogPusher { new CatalogPusher(catalog).init(); } - private constructor(private catalog: CatalogEntityRegistry) { - } + private constructor(private catalog: CatalogEntityRegistry) {} init() { - const disposers: { (): void; }[] = []; + const disposers: Disposer[] = []; - disposers.push(autorun(() => { - this.broadcast(); + disposers.push(reaction(() => this.catalog.items, (items) => { + broadcastMessage("catalog:items", toJS(items, { recurseEverything: true })); + }, { + fireImmediately: true, })); const listener = subscribeToBroadcast("catalog:broadcast", () => { - this.broadcast(); + broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true })); }); disposers.push(() => unsubscribeFromBroadcast("catalog:broadcast", listener)); return disposers; } - - broadcast() { - broadcastMessage("catalog:items", toJS(this.catalog.items)); - } } diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts new file mode 100644 index 0000000000..beda3ca890 --- /dev/null +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -0,0 +1,252 @@ +import { ObservableMap } from "mobx"; +import { CatalogEntity } from "../../../common/catalog"; +import { loadFromOptions } from "../../../common/kube-helpers"; +import { Cluster } from "../../cluster"; +import { computeDiff, configToModels } from "../kubeconfig-sync"; +import mockFs from "mock-fs"; +import fs from "fs"; + +describe("kubeconfig-sync.source tests", () => { + beforeEach(() => { + mockFs(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + describe("configsToModels", () => { + it("should filter out invalid split configs", () => { + const config = loadFromOptions({ + clusters: [], + users: [], + contexts: [], + currentContext: "foobar" + }); + + expect(configToModels(config, "").length).toBe(0); + }); + + it("should keep a single valid split config", () => { + const config = loadFromOptions({ + clusters: [{ + name: "cluster-name", + server: "1.2.3.4", + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }], + contexts: [{ + cluster: "cluster-name", + name: "context-name", + user: "user-name", + }], + currentContext: "foobar" + }); + + const models = configToModels(config, "/bar"); + + expect(models.length).toBe(1); + expect(models[0].contextName).toBe("context-name"); + expect(models[0].kubeConfigPath).toBe("/bar"); + }); + }); + + describe("computeDiff", () => { + it("should leave an empty source empty if there are no entries", () => { + const contents = ""; + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(0); + }); + + it("should add only the valid clusters to the source", () => { + const contents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + fs.writeFileSync(filePath, contents); + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(1); + + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(c.contextName).toBe("context-name"); + }); + + it("should remove a cluster when it is removed from the contents", () => { + const contents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + fs.writeFileSync(filePath, contents); + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(1); + + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(c.contextName).toBe("context-name"); + + computeDiff("{}", rootSource, port, filePath); + + expect(rootSource.size).toBe(0); + }); + + it("should remove only the cluster that it is removed from the contents", () => { + const contents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }, { + name: "user-name-2", + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-name-2", + context: { + cluster: "cluster-name", + user: "user-name-2", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + fs.writeFileSync(filePath, contents); + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(2); + + { + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(["context-name", "context-name-2"].includes(c.contextName)).toBe(true); + } + + const newContents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }, { + name: "user-name-2", + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + + computeDiff(newContents, rootSource, port, filePath); + + expect(rootSource.size).toBe(1); + + { + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(c.contextName).toBe("context-name"); + } + }); + }); +}); diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts new file mode 100644 index 0000000000..5824760082 --- /dev/null +++ b/src/main/catalog-sources/index.ts @@ -0,0 +1 @@ +export { KubeconfigSyncManager } from "./kubeconfig-sync"; diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts new file mode 100644 index 0000000000..9cf2bf57d4 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -0,0 +1,251 @@ +import { action, observable, IComputedValue, computed, ObservableMap, runInAction } from "mobx"; +import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog"; +import { watch } from "chokidar"; +import fs from "fs"; +import fse from "fs-extra"; +import * as uuid from "uuid"; +import stream from "stream"; +import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; +import logger from "../logger"; +import { KubeConfig } from "@kubernetes/client-node"; +import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers"; +import { Cluster } from "../cluster"; +import { catalogEntityFromCluster } from "../cluster-manager"; +import { UserStore } from "../../common/user-store"; +import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; + +const logPrefix = "[KUBECONFIG-SYNC]:"; + +export class KubeconfigSyncManager extends Singleton { + protected sources = observable.map, Disposer]>(); + protected syncing = false; + protected syncListDisposer?: Disposer; + + protected static readonly syncName = "lens:kube-sync"; + + @action + startSync(port: number): void { + if (this.syncing) { + return; + } + + this.syncing = true; + + logger.info(`${logPrefix} starting requested syncs`); + + catalogEntityRegistry.addComputedSource(KubeconfigSyncManager.syncName, computed(() => ( + Array.from(iter.flatMap( + this.sources.values(), + ([entities]) => entities.get() + )) + ))); + + // This must be done so that c&p-ed clusters are visible + this.startNewSync(ClusterStore.storedKubeConfigFolder, port); + + for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) { + this.startNewSync(filePath, port); + } + + this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => { + switch (change.type) { + case "add": + this.startNewSync(change.name, port); + break; + case "delete": + this.stopOldSync(change.name); + break; + } + }); + } + + @action + stopSync() { + this.syncListDisposer?.(); + + for (const filePath of this.sources.keys()) { + this.stopOldSync(filePath); + } + + catalogEntityRegistry.removeSource(KubeconfigSyncManager.syncName); + this.syncing = false; + } + + @action + protected async startNewSync(filePath: string, port: number): Promise { + if (this.sources.has(filePath)) { + // don't start a new sync if we already have one + return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath }); + } + + try { + this.sources.set(filePath, await watchFileChanges(filePath, port)); + + logger.info(`${logPrefix} starting sync of file/folder`, { filePath }); + logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); + } catch (error) { + logger.warn(`${logPrefix} failed to start watching changes: ${error}`); + } + } + + @action + protected stopOldSync(filePath: string): void { + if (!this.sources.delete(filePath)) { + // already stopped + return void logger.debug(`${logPrefix} no syncing file/folder to stop`, { filePath }); + } + + logger.info(`${logPrefix} stopping sync of file/folder`, { filePath }); + logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); + } +} + +// exported for testing +export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] { + const validConfigs = []; + + for (const contextConfig of splitConfig(config)) { + const error = validateKubeConfig(contextConfig, contextConfig.currentContext); + + if (error) { + logger.debug(`${logPrefix} context failed validation: ${error}`, { context: contextConfig.currentContext, filePath }); + } else { + validConfigs.push({ + kubeConfigPath: filePath, + contextName: contextConfig.currentContext, + }); + } + } + + return validConfigs; +} + +type RootSourceValue = [Cluster, CatalogEntity]; +type RootSource = ObservableMap; + +// exported for testing +export function computeDiff(contents: string, source: RootSource, port: number, filePath: string): void { + runInAction(() => { + try { + const rawModels = configToModels(loadConfigFromString(contents), filePath); + const models = new Map(rawModels.map(m => [m.contextName, m])); + + logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); + + for (const [contextName, value] of source) { + const model = models.get(contextName); + + // remove and disconnect clusters that were removed from the config + if (!model) { + value[0].disconnect(); + source.delete(contextName); + logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); + continue; + } + + // TODO: For the update check we need to make sure that the config itself hasn't changed. + // Probably should make it so that cluster keeps a copy of the config in its memory and + // diff against that + + // or update the model and mark it as not needed to be added + value[0].updateModel(model); + models.delete(contextName); + logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName }); + } + + for (const [contextName, model] of models) { + // add new clusters to the source + try { + const cluster = new Cluster({ ...model, id: uuid.v4() }); + + if (!cluster.apiUrl) { + throw new Error("Cluster constructor failed, see above error"); + } + + cluster.init(port); + + const entity = catalogEntityFromCluster(cluster); + + entity.metadata.labels.file = filePath; + source.set(contextName, [cluster, entity]); + + logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName }); + } catch (error) { + logger.warn(`${logPrefix} Failed to create cluster from model: ${error}`, { filePath, contextName }); + } + } + } catch (error) { + logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath }); + source.clear(); // clear source if we have failed so as to not show outdated information + } + }); +} + +function diffChangedConfig(filePath: string, source: RootSource, port: number): Disposer { + logger.debug(`${logPrefix} file changed`, { filePath }); + + // TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out) + const fileReader = fs.createReadStream(filePath, { + mode: fs.constants.O_RDONLY, + }); + const readStream: stream.Readable = fileReader; + const bufs: Buffer[] = []; + let closed = false; + + const cleanup = () => { + closed = true; + fileReader.close(); // This may not close the stream. + // Artificially marking end-of-stream, as if the underlying resource had + // indicated end-of-file by itself, allows the stream to close. + // This does not cancel pending read operations, and if there is such an + // operation, the process may still not be able to exit successfully + // until it finishes. + fileReader.push(null); + fileReader.read(0); + readStream.removeAllListeners(); + }; + + readStream + .on("data", chunk => bufs.push(chunk)) + .on("close", () => cleanup()) + .on("error", error => { + cleanup(); + logger.warn(`${logPrefix} failed to read file: ${error}`, { filePath }); + }) + .on("end", () => { + if (!closed) { + computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath); + } + }); + + return cleanup; +} + +async function watchFileChanges(filePath: string, port: number): Promise<[IComputedValue, Disposer]> { + const stat = await fse.stat(filePath); // traverses symlinks, is a race condition + const watcher = watch(filePath, { + followSymlinks: true, + depth: stat.isDirectory() ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) + disableGlobbing: true, + }); + const rootSource = new ExtendedObservableMap>(observable.map); + const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); + const stoppers = new Map(); + + watcher + .on("change", (childFilePath) => { + stoppers.get(childFilePath)(); + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); + }) + .on("add", (childFilePath) => { + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); + }) + .on("unlink", (childFilePath) => { + stoppers.get(childFilePath)(); + stoppers.delete(childFilePath); + rootSource.delete(childFilePath); + }) + .on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); + + return [derivedSource, () => watcher.close()]; +} diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index b3361db3d7..259a95e5db 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,13 +1,13 @@ import "../common/cluster-ipc"; import type http from "http"; import { ipcMain } from "electron"; -import { action, autorun, makeObservable, observable, reaction } from "mobx"; +import { action, autorun, observable, reaction, makeObservable } from "mobx"; +import { Singleton, toJS } from "../common/utils"; import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; -import { CatalogEntity, CatalogEntityData, catalogEntityRegistry } from "../common/catalog"; -import { Singleton, toJS } from "../common/utils"; +import { CatalogEntity, catalogEntityRegistry } from "../common/catalog"; import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster"; const clusterOwnerRef = "ClusterManager"; @@ -20,7 +20,7 @@ export class ClusterManager extends Singleton { makeObservable(this); - catalogEntityRegistry.addSource("lens:kubernetes-clusters", this.catalogSource); + catalogEntityRegistry.addObservableSource("lens:kubernetes-clusters", this.catalogSource); // auto-init clusters reaction(() => ClusterStore.getInstance().enabledClustersList, (clusters) => { clusters.forEach((cluster) => { @@ -32,7 +32,7 @@ export class ClusterManager extends Singleton { }, { fireImmediately: true }); - reaction(() => toJS(ClusterStore.getInstance().enabledClustersList), () => { + reaction(() => toJS(ClusterStore.getInstance().enabledClustersList, { recurseEverything: true }), () => { this.updateCatalogSource(ClusterStore.getInstance().enabledClustersList); }, { fireImmediately: true }); @@ -40,6 +40,7 @@ export class ClusterManager extends Singleton { this.syncClustersFromCatalog(entities); }); + // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(ClusterStore.getInstance().removedClusters.values()); @@ -55,27 +56,22 @@ export class ClusterManager extends Singleton { delay: 250 }); - ipcMain.on("network:offline", () => { - this.onNetworkOffline(); - }); - ipcMain.on("network:online", () => { - this.onNetworkOnline(); - }); + ipcMain.on("network:offline", () => { this.onNetworkOffline(); }); + ipcMain.on("network:online", () => { this.onNetworkOnline(); }); } - @action - protected updateCatalogSource(clusters: Cluster[]) { - this.catalogSource.forEach((entity, index) => { - const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id); + @action protected updateCatalogSource(clusters: Cluster[]) { + this.catalogSource.replace(this.catalogSource.filter(entity => ( + clusters.find((cluster) => entity.metadata.uid === cluster.id) + ))); - if (clusterIndex === -1) { - this.catalogSource.splice(index, 1); + for (const cluster of clusters) { + if (cluster.ownerRef) { + continue; } - }); - clusters.filter((c) => !c.ownerRef).forEach((cluster) => { const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id); - const newEntity = this.catalogEntityFromCluster(cluster); + const newEntity = catalogEntityFromCluster(cluster); if (entityIndex === -1) { this.catalogSource.push(newEntity); @@ -90,11 +86,15 @@ export class ClusterManager extends Singleton { }; this.catalogSource.splice(entityIndex, 1, newEntity); } - }); + } } @action syncClustersFromCatalog(entities: KubernetesCluster[]) { - entities.filter((entity) => entity.metadata.source !== "local").forEach((entity: KubernetesCluster) => { + for (const entity of entities) { + if (entity.metadata.source !== "local") { + continue; + } + const cluster = ClusterStore.getInstance().getById(entity.metadata.uid); if (!cluster) { @@ -110,7 +110,7 @@ export class ClusterManager extends Singleton { }); } else { cluster.enabled = true; - if (!cluster.ownerRef) cluster.ownerRef = clusterOwnerRef; + cluster.ownerRef ||= clusterOwnerRef; cluster.preferences.clusterName = entity.metadata.name; cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; @@ -120,34 +120,7 @@ export class ClusterManager extends Singleton { active: !cluster.disconnected }; } - }); - } - - protected catalogEntityFromCluster(cluster: Cluster) { - const data: CatalogEntityData = toJS({ - apiVersion: "entity.k8slens.dev/v1alpha1", - kind: "KubernetesCluster", - metadata: { - uid: cluster.id, - name: cluster.name, - source: "local", - labels: { - "distro": (cluster.metadata["distribution"] || "unknown").toString() - } - }, - spec: { - kubeconfigPath: cluster.kubeConfigPath, - kubeconfigContext: cluster.contextName - }, - status: { - phase: cluster.disconnected ? "disconnected" : "connected", - reason: "", - message: "", - active: !cluster.disconnected - } - }); - - return new KubernetesCluster(data as any); + } } protected onNetworkOffline() { @@ -200,3 +173,28 @@ export class ClusterManager extends Singleton { return cluster; } } + +export function catalogEntityFromCluster(cluster: Cluster) { + return new KubernetesCluster(toJS({ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: cluster.id, + name: cluster.name, + source: "local", + labels: { + distro: cluster.distribution, + } + }, + spec: { + kubeconfigPath: cluster.kubeConfigPath, + kubeconfigContext: cluster.contextName + }, + status: { + phase: cluster.disconnected ? "disconnected" : "connected", + reason: "", + message: "", + active: !cluster.disconnected + } + })); +} diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 6be3283a0c..9c24bf8d20 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,5 +1,5 @@ import { ipcMain } from "electron"; -import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store"; +import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store"; import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; @@ -58,7 +58,7 @@ export interface ClusterState { */ export class Cluster implements ClusterModel, ClusterState { /** Unique id for a cluster */ - public id: ClusterId; + public readonly id: ClusterId; /** * Kubectl * @@ -86,7 +86,7 @@ export class Cluster implements ClusterModel, ClusterState { whenReady = when(() => this.ready); /** - * Is cluster object initializinng on-going + * Is cluster object initializing on-going * * @observable */ @@ -232,6 +232,10 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.clusterName || this.contextName; } + @computed get distribution(): string { + return this.metadata.distribution?.toString() || "unknown"; + } + /** * Prometheus preferences * @@ -253,12 +257,17 @@ export class Cluster implements ClusterModel, ClusterState { constructor(model: ClusterModel) { makeObservable(this); + + this.id = model.id; this.updateModel(model); try { const kubeconfig = this.getKubeconfig(); + const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); - validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false }); + if (error) { + throw error; + } this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; } catch (err) { logger.error(err); @@ -279,8 +288,34 @@ export class Cluster implements ClusterModel, ClusterState { * * @param model */ - @action updateModel(model: ClusterModel) { - Object.assign(this, model); + @action updateModel(model: UpdateClusterModel) { + // Note: do not assign ID as that should never be updated + + this.kubeConfigPath = model.kubeConfigPath; + + if (model.workspace) { + this.workspace = model.workspace; + } + + if (model.contextName) { + this.contextName = model.contextName; + } + + if (model.preferences) { + this.preferences = model.preferences; + } + + if (model.metadata) { + this.metadata = model.metadata; + } + + if (model.ownerRef) { + this.ownerRef = model.ownerRef; + } + + if (model.accessibleNamespaces) { + this.accessibleNamespaces = model.accessibleNamespaces; + } } /** diff --git a/src/main/index.ts b/src/main/index.ts index 6b1079cf5c..dcab3a75e6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -33,6 +33,7 @@ import { CatalogPusher } from "./catalog-pusher"; import { catalogEntityRegistry } from "../common/catalog"; import { HotbarStore } from "../common/hotbar-store"; import { HelmRepoManager } from "./helm/helm-repo-manager"; +import { KubeconfigSyncManager } from "./catalog-sources"; import configurePackages from "../common/configure-packages"; const workingDir = path.join(app.getPath("appData"), appName); @@ -133,6 +134,9 @@ app.on("ready", async () => { const clusterManager = ClusterManager.getInstance(); + // create kubeconfig sync manager + KubeconfigSyncManager.createInstance().startSync(clusterManager.port); + // run proxy try { logger.info("🔌 Starting LensProxy"); @@ -239,6 +243,7 @@ app.on("will-quit", (event) => { logger.info("APP:QUIT"); appEventBus.emit({ name: "app", action: "close" }); ClusterManager.getInstance(false)?.stop(); // close cluster connections + KubeconfigSyncManager.getInstance(false)?.stopSync(); if (blockQuit) { event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index c7ca6bfdda..3686876acf 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -10,6 +10,7 @@ import { 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"; export class WindowManager extends Singleton { protected mainWindow: BrowserWindow; @@ -48,6 +49,7 @@ export class WindowManager extends Singleton { this.mainWindow = new BrowserWindow({ x, y, width, height, + title: productName, show: false, minWidth: 700, // accommodate 800 x 600 display minimum minHeight: 500, // accommodate 800 x 600 display minimum diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index e7c14f587c..23720ec967 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -43,27 +43,6 @@ describe("CatalogEntityRegistry", () => { expect(catalog.items.length).toEqual(2); }); - it("ignores unknown items", () => { - const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); - const items = [{ - apiVersion: "entity.k8slens.dev/v1alpha1", - kind: "FooBar", - metadata: { - uid: "123", - name: "foobar", - source: "test", - labels: {} - }, - status: { - phase: "disconnected" - }, - spec: {} - }]; - - catalog.updateItems(items); - expect(catalog.items.length).toEqual(0); - }); - it("updates existing items", () => { const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); const items = [{ diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 97e6226523..bd1cfabbeb 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -5,6 +5,7 @@ import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegis export class CatalogEntityRegistry { @observable protected _items: CatalogEntity[] = observable.array([], { deep: true }); + @observable protected _activeEntity: CatalogEntity; constructor(private categoryRegistry: CatalogCategoryRegistry) { makeObservable(this); @@ -18,27 +19,15 @@ export class CatalogEntityRegistry { } @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { - this._items.forEach((item, index) => { - const foundIndex = items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid); + this._items = items.map(data => this.categoryRegistry.getEntityForData(data)); + } - if (foundIndex === -1) { - this._items.splice(index, 1); - } - }); + set activeEntity(entity: CatalogEntity) { + this._activeEntity = entity; + } - items.forEach((data) => { - const item = this.categoryRegistry.getEntityForData(data); - - if (!item) return; // invalid data - - const index = this._items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid); - - if (index === -1) { - this._items.push(item); - } else { - this._items.splice(index, 1, item); - } - }); + get activeEntity() { + return this._activeEntity; } get items() { diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 2c0739c6eb..8b0a9421d8 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -50,6 +50,11 @@ export interface IKubeApiQueryParams { fieldSelector?: string | string[]; // restrict list of objects by their fields, e.g. fieldSelector: "field=name" } +export interface KubeApiListOptions { + namespace?: string; + reqInit?: RequestInit; +} + export interface IKubePreferredVersion { preferredVersion?: { version: string; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 846a7b6fb8..f912266e7e 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -5,6 +5,7 @@ import * as MobxReact from "mobx-react"; import * as ReactRouter from "react-router"; import * as ReactRouterDom from "react-router-dom"; import * as LensExtensions from "../extensions/extension-api"; +import configurePackages from "../common/configure-packages"; import { render, unmountComponentAtNode } from "react-dom"; import { delay } from "../common/utils"; import { isDevelopment, isMac } from "../common/vars"; @@ -20,7 +21,7 @@ import { LensApp } from "./lens-app"; import { ThemeStore } from "./theme.store"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; -import configurePackages from "../common/configure-packages"; +import { DefaultProps } from "./mui-base-theme"; configurePackages(); @@ -94,7 +95,7 @@ export async function bootstrap(App: AppComponent) { }); render(<> {isMac &&
} - + {DefaultProps(App)} , rootElem); } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index bad2155928..41302e556e 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -11,7 +11,7 @@ import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { Icon } from "../icon"; import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; -import { ClusterModel, ClusterStore } from "../../../common/cluster-store"; +import { ClusterStore } from "../../../common/cluster-store"; import { v4 as uuid } from "uuid"; import { navigate } from "../../navigation"; import { UserStore } from "../../../common/user-store"; @@ -137,36 +137,28 @@ export class AddCluster extends React.Component { }; @action - addClusters = () => { - let newClusters: ClusterModel[] = []; - + addClusters = (): void => { try { if (!this.selectedContexts.length) { - this.error = "Please select at least one cluster context"; - - return; + return void (this.error = "Please select at least one cluster context"); } + this.error = ""; this.isWaiting = true; appEventBus.emit({ name: "cluster-add", action: "click" }); - newClusters = this.selectedContexts.filter(context => { - try { - const kubeConfig = this.kubeContexts.get(context); + const newClusters = this.selectedContexts.filter(context => { + const kubeConfig = this.kubeContexts.get(context); + const error = validateKubeConfig(kubeConfig, context); - validateKubeConfig(kubeConfig, context); + if (error) { + this.error = error.toString(); - return true; - } catch (err) { - this.error = String(err.message); - - if (err instanceof ExecValidationNotFoundError) { + if (error instanceof ExecValidationNotFoundError) { Notifications.error(<>Error while adding cluster(s): {this.error}); - - return false; - } else { - throw new Error(err); } } + + return Boolean(!error); }).map(context => { const clusterId = uuid(); const kubeConfig = this.kubeContexts.get(context); @@ -209,7 +201,7 @@ export class AddCluster extends React.Component { Add clusters by clicking the Add Cluster button. You'll need to obtain a working kubeconfig for the cluster you want to add. You can either browse it from the file system or paste it as a text from the clipboard. - Read more about adding clusters here. + Read more about adding clusters here.

); } diff --git a/src/renderer/components/+catalog/catalog-add-button.scss b/src/renderer/components/+catalog/catalog-add-button.scss index 4b2bb13490..ede0e691e1 100644 --- a/src/renderer/components/+catalog/catalog-add-button.scss +++ b/src/renderer/components/+catalog/catalog-add-button.scss @@ -12,7 +12,7 @@ } } -.MuiTooltip-popper { +.catalogSpeedDialPopper { .MuiTooltip-tooltip { background-color: #222; font-size: 12px diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx index 376036f0ae..1d6328f838 100644 --- a/src/renderer/components/+catalog/catalog-add-button.tsx +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -79,6 +79,9 @@ export class CatalogAddButton extends React.Component { icon={} tooltipTitle={menuItem.title} onClick={() => menuItem.onClick()} + TooltipClasses={{ + popper: "catalogSpeedDialPopper" + }} />; })} diff --git a/src/renderer/components/+catalog/catalog.scss b/src/renderer/components/+catalog/catalog.scss index e88e786800..6e711cdf2b 100644 --- a/src/renderer/components/+catalog/catalog.scss +++ b/src/renderer/components/+catalog/catalog.scss @@ -57,4 +57,23 @@ 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; + } + } + } } diff --git a/src/renderer/components/+preferences/kubeconfig-syncs.tsx b/src/renderer/components/+preferences/kubeconfig-syncs.tsx new file mode 100644 index 0000000000..89432dcac8 --- /dev/null +++ b/src/renderer/components/+preferences/kubeconfig-syncs.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { remote } from "electron"; +import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText, Paper } from "@material-ui/core"; +import { Description, Folder, Delete, HelpOutline } from "@material-ui/icons"; +import { action, computed, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import fse from "fs-extra"; +import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store"; +import { Button } from "../button"; +import { SubTitle } from "../layout/sub-title"; +import { Spinner } from "../spinner"; +import logger from "../../../main/logger"; +import { iter } from "../../utils"; + +interface SyncInfo { + type: "file" | "folder" | "unknown"; +} + +interface Entry extends Value { + filePath: string; +} + +interface Value { + data: KubeconfigSyncValue; + info: SyncInfo; +} + +async function getMapEntry({ filePath, ...data}: KubeconfigSyncEntry): Promise<[string, Value]> { + try { + // stat follows the stat(2) linux syscall spec, namely it follows symlinks + const stats = await fse.stat(filePath); + + if (stats.isFile()) { + return [filePath, { info: { type: "file" }, data }]; + } + + if (stats.isDirectory()) { + return [filePath, { info: { type: "folder" }, data }]; + } + + logger.warn("[KubeconfigSyncs]: unknown stat entry", { stats }); + + return [filePath, { info: { type: "unknown" }, data }]; + } catch (error) { + logger.warn(`[KubeconfigSyncs]: failed to stat entry: ${error}`, { error }); + + return [filePath, { info: { type: "unknown" }, data }]; + } +} + +@observer +export class KubeconfigSyncs extends React.Component { + syncs = observable.map(); + @observable loaded = false; + + async componentDidMount() { + const mapEntries = await Promise.all( + iter.map( + UserStore.getInstance().syncKubeconfigEntries, + ([filePath, ...value]) => getMapEntry({ filePath, ...value }), + ), + ); + + this.syncs.replace(mapEntries); + this.loaded = true; + + disposeOnUnmount(this, [ + reaction(() => Array.from(this.syncs.entries(), ([filePath, { data }]) => [filePath, data]), syncs => { + UserStore.getInstance().syncKubeconfigEntries.replace(syncs); + }) + ]); + } + + @computed get syncsList(): Entry[] | undefined { + if (!this.loaded) { + return undefined; + } + + return Array.from(this.syncs.entries(), ([filePath, value]) => ({ filePath, ...value })); + } + + @action + openFileDialog = async () => { + const { dialog, BrowserWindow } = remote; + const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { + properties: ["openFile", "showHiddenFiles", "multiSelections", "openDirectory"], + message: "Select kubeconfig file(s) and folder(s)", + buttonLabel: "Sync", + }); + + if (canceled) { + return; + } + + const newEntries = await Promise.all(filePaths.map(filePath => getMapEntry({ filePath }))); + + for (const [filePath, info] of newEntries) { + this.syncs.set(filePath, info); + } + }; + + renderEntryIcon(entry: Entry) { + switch (entry.info.type) { + case "file": + return ; + case "folder": + return ; + case "unknown": + return ; + } + } + + renderEntry = (entry: Entry) => { + return ( + + + + + {this.renderEntryIcon(entry)} + + + + + this.syncs.delete(entry.filePath)} + > + + + + + + ); + }; + + renderEntries() { + const entries = this.syncsList; + + if (!entries) { + return ( +
+ +
+ ); + } + + return ( + + {entries.map(this.renderEntry)} + + ); + } + + render() { + return ( + <> +
+ +
+ + ); + } +} diff --git a/src/renderer/components/+preferences/preferences.scss b/src/renderer/components/+preferences/preferences.scss index 5c02312432..4320def3d0 100644 --- a/src/renderer/components/+preferences/preferences.scss +++ b/src/renderer/components/+preferences/preferences.scss @@ -1,2 +1,32 @@ .Preferences { -} \ No newline at end of file + .loading-spinner { + margin: auto; + } + + .kubeconfig-sync-list { + .entry { + &.MuiPaper-root { + background-color: var(--inputControlBackground); + margin-bottom: var(--flex-gap, 1em); + color: inherit; + } + + .MuiAvatar-root { + color: var(--buttonPrimaryBackground); + font-size: calc(2.5 * var(--unit)); + } + + .description { + font-family: monospace; + + .MuiTypography-body1 { + font-size: var(--font-size); + } + } + + .action .MuiIconButton-root { + font-size: calc(2.5 * var(--unit)); + } + } + } +} diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index 6c68008027..a58f0e5939 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -18,6 +18,7 @@ import { KubectlBinaries } from "./kubectl-binaries"; import { navigation } from "../../navigation"; import { Tab, Tabs } from "../tabs"; import { FormSwitch, Switcher } from "../switch"; +import { KubeconfigSyncs } from "./kubeconfig-syncs"; enum Pages { Application = "application", @@ -214,7 +215,6 @@ export class Preferences extends React.Component { )} - {this.activeTab == Pages.Kubernetes && (
@@ -222,20 +222,23 @@ export class Preferences extends React.Component {

+
+

Kubeconfig Syncs

+ +
+

Helm Charts

)} - {this.activeTab == Pages.Telemetry && (

Telemetry

{telemetryExtensions.map(this.renderExtension)}
)} - {this.activeTab == Pages.Extensions && (

Extensions

diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index c09b4d7057..fb1bb9e497 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -2,49 +2,21 @@ import "./cluster-manager.scss"; import React from "react"; import { Redirect, Route, Switch } from "react-router"; -import { comparer, reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observer } from "mobx-react"; import { BottomBar } from "./bottom-bar"; import { Catalog, catalogRoute } from "../+catalog"; import { Preferences, preferencesRoute } from "../+preferences"; import { AddCluster, addClusterRoute } from "../+add-cluster"; import { ClusterView } from "./cluster-view"; import { clusterViewRoute } from "./cluster-view.route"; -import { ClusterStore } from "../../../common/cluster-store"; -import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import { globalPageRegistry } from "../../../extensions/registries/page-registry"; import { Extensions, extensionsRoute } from "../+extensions"; -import { getMatchedClusterId } from "../../navigation"; import { HotbarMenu } from "../hotbar/hotbar-menu"; import { EntitySettings, entitySettingsRoute } from "../+entity-settings"; import { Welcome, welcomeRoute, welcomeURL } from "../+welcome"; @observer export class ClusterManager extends React.Component { - componentDidMount() { - const getMatchedCluster = () => ClusterStore.getInstance().getById(getMatchedClusterId()); - - disposeOnUnmount(this, [ - reaction(getMatchedClusterId, initView, { - fireImmediately: true - }), - reaction(() => !getMatchedClusterId(), () => ClusterStore.getInstance().setActive(null)), - reaction(() => [ - getMatchedClusterId(), // refresh when active cluster-view changed - hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded - getMatchedCluster()?.available, // refresh on disconnect active-cluster - getMatchedCluster()?.ready, // refresh when cluster ready-state change - ], refreshViews, { - fireImmediately: true, - equals: comparer.shallow, - }), - ]); - } - - componentWillUnmount() { - lensViews.clear(); - } - render() { return (
diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index c3e6cabf45..92ed9d0ecc 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -44,10 +44,6 @@ export class ClusterStatus extends React.Component { error: res.error, }); }); - - if (this.cluster.disconnected) { - await this.activateCluster(); - } } componentWillUnmount() { diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index fbc55d8db3..5e46c63c3b 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -5,11 +5,12 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { RouteComponentProps } from "react-router"; import { IClusterViewRouteParams } from "./cluster-view.route"; import { ClusterStatus } from "./cluster-status"; -import { hasLoadedView } from "./lens-views"; +import { hasLoadedView, initView, refreshViews } from "./lens-views"; import { Cluster } from "../../../main/cluster"; -import { navigate } from "../../navigation"; -import { catalogURL } from "../+catalog"; import { ClusterStore } from "../../../common/cluster-store"; +import { requestMain } from "../../../common/ipc"; +import { clusterActivateHandler } from "../../../common/cluster-ipc"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; interface Props extends RouteComponentProps { } @@ -26,19 +27,43 @@ export class ClusterView extends React.Component { async componentDidMount() { disposeOnUnmount(this, [ - reaction(() => this.clusterId, clusterId => ClusterStore.getInstance().setActive(clusterId), { + reaction(() => this.clusterId, (clusterId) => { + this.showCluster(clusterId); + }, { fireImmediately: true, - }), - reaction(() => this.cluster.online, (online) => { - if (!online) navigate(catalogURL()); }) ]); } + componentWillUnmount() { + this.hideCluster(); + } + + showCluster(clusterId: string) { + initView(clusterId); + requestMain(clusterActivateHandler, this.clusterId, false); + + const entity = catalogEntityRegistry.getById(this.clusterId); + + if (entity) { + catalogEntityRegistry.activeEntity = entity; + } + } + + hideCluster() { + refreshViews(); + + if (catalogEntityRegistry.activeEntity?.metadata?.uid === this.clusterId) { + catalogEntityRegistry.activeEntity = null; + } + } + render() { const { cluster } = this; const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id) || !cluster.ready); + refreshViews(cluster.id); + return (
{showStatus && ( diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 30fae018cc..4b08ad9efe 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -1,7 +1,8 @@ import { observable, when } from "mobx"; import { ClusterId, ClusterStore, getClusterFrameUrl } from "../../../common/cluster-store"; -import { getMatchedClusterId } from "../../navigation"; +import { navigate } from "../../navigation"; import logger from "../../../main/logger"; +import { catalogURL } from "../+catalog"; export interface LensView { isLoaded?: boolean @@ -16,9 +17,12 @@ export function hasLoadedView(clusterId: ClusterId): boolean { } export async function initView(clusterId: ClusterId) { + refreshViews(clusterId); + if (!clusterId || lensViews.has(clusterId)) { return; } + const cluster = ClusterStore.getInstance().getById(clusterId); if (!cluster) { @@ -51,16 +55,23 @@ export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrame logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); lensViews.delete(clusterId); + const wasVisible = iframe.style.display !== "none"; + // Keep frame in DOM to avoid possible bugs when same cluster re-created after being removed. // In that case for some reasons `webFrame.routingId` returns some previous frameId (usage in app.tsx) // Issue: https://github.com/lensapp/lens/issues/811 + iframe.style.display = "none"; iframe.dataset.meta = `${iframe.name} was removed at ${new Date().toLocaleString()}`; iframe.removeAttribute("name"); iframe.contentWindow.postMessage("teardown", "*"); + + if (wasVisible) { + navigate(catalogURL()); + } } -export function refreshViews() { - const cluster = ClusterStore.getInstance().getById(getMatchedClusterId()); +export function refreshViews(visibleClusterId?: string) { + const cluster = !visibleClusterId ? null : ClusterStore.getInstance().getById(visibleClusterId); lensViews.forEach(({ clusterId, view, isLoaded }) => { const isCurrent = clusterId === cluster?.id; diff --git a/src/renderer/components/dock/log-list.scss b/src/renderer/components/dock/log-list.scss index 8a39dcf925..5accd47fa6 100644 --- a/src/renderer/components/dock/log-list.scss +++ b/src/renderer/components/dock/log-list.scss @@ -30,6 +30,7 @@ span { -webkit-font-smoothing: auto; // Better readability on non-retina screens + white-space: pre; } span.overlay { diff --git a/src/renderer/components/hotbar/hotbar-icon.scss b/src/renderer/components/hotbar/hotbar-icon.scss index f75867d96c..1da432d568 100644 --- a/src/renderer/components/hotbar/hotbar-icon.scss +++ b/src/renderer/components/hotbar/hotbar-icon.scss @@ -48,7 +48,7 @@ margin: -8px; font-size: var(--font-size-small); background: var(--clusterMenuBackground); - color: white; + color: var(--textColorAccent); padding: 0px; border-radius: 50%; border: 2px solid var(--clusterMenuBackground); diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 18815d7173..e6bbb1a92f 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -123,7 +123,7 @@ export class HotbarIcon extends React.Component { generateAvatarStyle(entity: CatalogEntity): React.CSSProperties { return { - "backgroundColor": randomColor({ seed: entity.metadata.name, luminosity: "dark" }) + "backgroundColor": randomColor({ seed: `${entity.metadata.name}-${entity.metadata.source}`, luminosity: "dark" }) }; } @@ -145,7 +145,7 @@ export class HotbarIcon extends React.Component { return (
- {entity.metadata.name} + {entity.metadata.name} ({entity.metadata.source || "local"}) { } isActive(item: CatalogEntity) { - return ClusterStore.getInstance().activeClusterId == item.getId(); + return catalogEntityRegistry.activeEntity?.metadata?.uid == item.getId(); } getEntity(item: HotbarItem) { @@ -73,14 +72,6 @@ export class HotbarMenu extends React.Component { }); } - renderAddCellButton() { - return ( - - ); - } - render() { const { className } = this.props; const hotbarStore = HotbarStore.getInstance(); @@ -91,7 +82,6 @@ export class HotbarMenu extends React.Component {
{this.renderGrid()} - {this.hotbar.items.length != defaultHotbarCells && this.renderAddCellButton()}
this.previous()} /> @@ -120,23 +110,14 @@ function HotbarCell(props: HotbarCellProps) { const [animating, setAnimating] = useState(false); const onAnimationEnd = () => { setAnimating(false); }; const onClick = () => { setAnimating(true); }; - const onDeleteClick = (evt: Event | React.SyntheticEvent) => { - evt.stopPropagation(); - HotbarStore.getInstance().removeEmptyCell(props.index); - }; return (
{props.children} - {!props.children && ( -
- -
- )}
); } diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 924e313880..0c14f8eaeb 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -76,7 +76,7 @@ function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: (
Add Accessible Namespaces -

Cluster {ClusterStore.getInstance().active.name} does not have permissions to list namespaces. Please add the namespaces you have access to.

+

Cluster {ClusterStore.getInstance().getById(clusterId).name} does not have permissions to list namespaces. Please add the namespaces you have access to.