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 (
+ <>
+
+
+ void this.openFileDialog()}
+ />
+
+ Sync an individual file or all files in a folder (non-recursive).
+
+ {this.renderEntries()}
+
+ >
+ );
+ }
+}
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 {
+
+
)}
-
{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 (
- HotbarStore.getInstance().addEmptyCell()}>
-
-
- );
- }
-
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.
{
navigate(entitySettingsURL({ params: { entityId: clusterId }, fragment: "accessible-namespaces" }));
diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts
index 11a34c1765..351636259e 100644
--- a/src/renderer/kube-object.store.ts
+++ b/src/renderer/kube-object.store.ts
@@ -1,15 +1,19 @@
import type { ClusterContext } from "./components/context";
-import { action, computed, makeObservable, observable, reaction, when } from "mobx";
+
+import { action, computed, observable, reaction, when, makeObservable } from "mobx";
+import { noop, rejectPromiseBy } from "./utils";
import { KubeObject, KubeStatus } from "./api/kube-object";
import { IKubeWatchEvent } from "./api/kube-watch-api";
import { ItemStore } from "./item.store";
import { apiManager } from "./api/api-manager";
import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api";
import { KubeJsonApiData } from "./api/kube-json-api";
+import { Notifications } from "./components/notifications";
export interface KubeObjectStoreLoadingParams {
namespaces: string[];
api?: KubeApi;
+ reqInit?: RequestInit;
}
export abstract class KubeObjectStore extends ItemStore {
@@ -20,9 +24,10 @@ export abstract class KubeObjectStore extends ItemSt
abstract api: KubeApi;
public readonly limit?: number;
public readonly bufferSize: number = 50000;
- private loadedNamespaces: string[] = [];
+ @observable private loadedNamespaces?: string[];
contextReady = when(() => Boolean(this.context));
+ namespacesReady = when(() => Boolean(this.loadedNamespaces));
constructor() {
super();
@@ -103,10 +108,10 @@ export abstract class KubeObjectStore extends ItemSt
}
}
- protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise {
+ protected async loadItems({ namespaces, api, reqInit }: KubeObjectStoreLoadingParams): Promise {
if (this.context?.cluster.isAllowedResource(api.kind)) {
if (!api.isNamespaced) {
- return api.list({}, this.query);
+ return api.list({ reqInit }, this.query);
}
const isLoadingAll = this.context.allNamespaces?.length > 1
@@ -116,13 +121,13 @@ export abstract class KubeObjectStore extends ItemSt
if (isLoadingAll) {
this.loadedNamespaces = [];
- return api.list({}, this.query);
+ return api.list({ reqInit }, this.query);
} else {
this.loadedNamespaces = namespaces;
return Promise // load resources per namespace
- .all(namespaces.map(namespace => api.list({ namespace })))
- .then(items => items.flat());
+ .all(namespaces.map(namespace => api.list({ namespace, reqInit })))
+ .then(items => items.flat().filter(Boolean));
}
}
@@ -134,7 +139,7 @@ export abstract class KubeObjectStore extends ItemSt
}
@action
- async loadAll(options: { namespaces?: string[], merge?: boolean } = {}): Promise {
+ async loadAll(options: { namespaces?: string[], merge?: boolean, reqInit?: RequestInit } = {}): Promise {
await this.contextReady;
this.isLoading = true;
@@ -142,9 +147,10 @@ export abstract class KubeObjectStore extends ItemSt
const {
namespaces = this.context.allNamespaces, // load all namespaces by default
merge = true, // merge loaded items or return as result
+ reqInit,
} = options;
- const items = await this.loadItems({ namespaces, api: this.api });
+ const items = await this.loadItems({ namespaces, api: this.api, reqInit });
if (merge) {
this.mergeItems(items, { replace: false });
@@ -157,7 +163,10 @@ export abstract class KubeObjectStore extends ItemSt
return items;
} catch (error) {
- console.error("Loading store items failed", { error, store: this });
+ if (error.message) {
+ Notifications.error(error.message);
+ }
+ console.error("Loading store items failed", { error });
this.resetOnError(error);
this.failedLoading = true;
} finally {
@@ -274,17 +283,21 @@ export abstract class KubeObjectStore extends ItemSt
subscribe(apis = this.getSubscribeApis()) {
const abortController = new AbortController();
- const namespaces = [...this.loadedNamespaces];
- if (this.context.cluster?.isGlobalWatchEnabled && namespaces.length === 0) {
- apis.forEach(api => this.watchNamespace(api, "", abortController));
- } else {
- apis.forEach(api => {
- this.loadedNamespaces.forEach((namespace) => {
- this.watchNamespace(api, namespace, abortController);
- });
- });
- }
+ // This waits for the context and namespaces to be ready or fails fast if the disposer is called
+ Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])])
+ .then(() => {
+ if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
+ apis.forEach(api => this.watchNamespace(api, "", abortController));
+ } else {
+ apis.forEach(api => {
+ this.loadedNamespaces.forEach((namespace) => {
+ this.watchNamespace(api, namespace, abortController);
+ });
+ });
+ }
+ })
+ .catch(noop); // ignore DOMExceptions
return () => {
abortController.abort();
@@ -293,48 +306,38 @@ export abstract class KubeObjectStore extends ItemSt
private watchNamespace(api: KubeApi, namespace: string, abortController: AbortController) {
let timedRetry: NodeJS.Timeout;
+ const watch = () => api.watch({
+ namespace,
+ abortController,
+ callback
+ });
- abortController.signal.addEventListener("abort", () => clearTimeout(timedRetry));
+ const { signal } = abortController;
const callback = (data: IKubeWatchEvent, error: any) => {
- if (!this.isLoaded || abortController.signal.aborted) return;
+ if (!this.isLoaded || error instanceof DOMException) return;
if (error instanceof Response) {
if (error.status === 404) {
// api has gone, let's not retry
return;
- } else { // not sure what to do, best to retry
- if (timedRetry) clearTimeout(timedRetry);
- timedRetry = setTimeout(() => {
- api.watch({
- namespace,
- abortController,
- callback
- });
- }, 5000);
}
+
+ // not sure what to do, best to retry
+ clearTimeout(timedRetry);
+ timedRetry = setTimeout(watch, 5000);
} else if (error instanceof KubeStatus && error.code === 410) {
- if (timedRetry) clearTimeout(timedRetry);
+ clearTimeout(timedRetry);
// resourceVersion has gone, let's try to reload
timedRetry = setTimeout(() => {
- (namespace === "" ? this.loadAll({ merge: false }) : this.loadAll({ namespaces: [namespace] })).then(() => {
- api.watch({
- namespace,
- abortController,
- callback
- });
- });
+ (
+ namespace
+ ? this.loadAll({ namespaces: [namespace], reqInit: { signal } }) : this.loadAll({ merge: false, reqInit: { signal } })
+ ).then(watch);
}, 1000);
} else if (error) { // not sure what to do, best to retry
- if (timedRetry) clearTimeout(timedRetry);
-
- timedRetry = setTimeout(() => {
- api.watch({
- namespace,
- abortController,
- callback
- });
- }, 5000);
+ clearTimeout(timedRetry);
+ timedRetry = setTimeout(watch, 5000);
}
if (data) {
@@ -342,11 +345,8 @@ export abstract class KubeObjectStore extends ItemSt
}
};
- api.watch({
- namespace,
- abortController,
- callback: (data, error) => callback(data, error)
- });
+ signal.addEventListener("abort", () => clearTimeout(timedRetry));
+ watch();
}
@action.bound
diff --git a/src/renderer/mui-base-theme.tsx b/src/renderer/mui-base-theme.tsx
new file mode 100644
index 0000000000..284e2ca40a
--- /dev/null
+++ b/src/renderer/mui-base-theme.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { createMuiTheme, ThemeProvider } from "@material-ui/core";
+
+const defaultTheme = createMuiTheme({
+ props: {
+ MuiIconButton: {
+ color: "inherit",
+ },
+ MuiSvgIcon: {
+ fontSize: "inherit",
+ },
+ MuiTooltip: {
+ placement: "top",
+ }
+ },
+ overrides: {
+ MuiIconButton: {
+ root: {
+ "&:hover": {
+ color: "var(--iconActiveColor)",
+ backgroundColor: "var(--iconActiveBackground)",
+ }
+ }
+ }
+ },
+});
+
+export function DefaultProps(App: React.ComponentType) {
+ return (
+
+
+
+ );
+}
diff --git a/src/renderer/template.html b/src/renderer/template.html
index 71440e5645..c7df1ee507 100755
--- a/src/renderer/template.html
+++ b/src/renderer/template.html
@@ -2,7 +2,6 @@
- OpenLens - Open Source Kubernetes IDE
diff --git a/yarn.lock b/yarn.lock
index 392f2a650a..faad5c39ec 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -838,6 +838,13 @@
react-is "^16.8.0"
react-transition-group "^4.4.0"
+"@material-ui/icons@^4.11.2":
+ version "4.11.2"
+ resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5"
+ integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+
"@material-ui/lab@^4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
@@ -1364,13 +1371,13 @@
jest-diff "^25.2.1"
pretty-format "^25.2.1"
-"@types/jest@^25.2.3":
- version "25.2.3"
- resolved "https://registry.yarnpkg.com/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf"
- integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw==
+"@types/jest@^26.0.22":
+ version "26.0.22"
+ resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.22.tgz#8308a1debdf1b807aa47be2838acdcd91e88fbe6"
+ integrity sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw==
dependencies:
- jest-diff "^25.2.1"
- pretty-format "^25.2.1"
+ jest-diff "^26.0.0"
+ pretty-format "^26.0.0"
"@types/js-yaml@^3.12.1", "@types/js-yaml@^3.12.4":
version "3.12.4"
@@ -1465,9 +1472,9 @@
integrity sha512-e3sW4oEH0qS1QxSfX7PT6xIi5qk/YSMsrB9Lq8EtkhQBZB+bKyfkP+jpLJRySanvBhAQPSv2PEBe81M8Iy/7yg==
"@types/node@*":
- version "14.0.11"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.11.tgz#61d4886e2424da73b7b25547f59fdcb534c165a3"
- integrity sha512-lCvvI24L21ZVeIiyIUHZ5Oflv1hhHQ5E1S25IRlKIXaRkVgmXpJMI3wUJkmym2bTbCe+WoIibQnMVAU3FguaOg==
+ version "14.14.41"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.41.tgz#d0b939d94c1d7bd53d04824af45f1139b8c45615"
+ integrity sha512-dueRKfaJL4RTtSa7bWeTK1M+VH+Gns73oCgzvYfHZywRCoPSd8EkXBL0mZ9unPTveBn+D9phZBaxuzpwjWkW0g==
"@types/node@^10.12.0":
version "10.17.24"
@@ -1683,13 +1690,18 @@
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
-"@types/semver@^7.1.0", "@types/semver@^7.2.0":
+"@types/semver@^7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.2.0.tgz#0d72066965e910531e1db4621c15d0ca36b8d83b"
integrity sha512-TbB0A8ACUWZt3Y6bQPstW9QNbhNeebdgLX4T/ZfkrswAfUzRiXrgd9seol+X379Wa589Pu4UEx9Uok0D4RjRCQ==
dependencies:
"@types/node" "*"
+"@types/semver@^7.3.4":
+ version "7.3.4"
+ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb"
+ integrity sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ==
+
"@types/serve-static@*":
version "1.13.6"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.6.tgz#866b1b8dec41c36e28c7be40ac725b88be43c5c1"
@@ -3194,14 +3206,6 @@ buffer@^5.1.0, buffer@^5.5.0:
base64-js "^1.0.2"
ieee754 "^1.1.4"
-builder-util-runtime@8.7.0:
- version "8.7.0"
- resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.7.0.tgz#e48ad004835c8284662e8eaf47a53468c66e8e8d"
- integrity sha512-G1AqqVM2vYTrSFR982c1NNzwXKrGLQjVjaZaWQdn4O6Z3YKjdMDofw88aD9jpyK9ZXkrCxR0tI3Qe9wNbyTlXg==
- dependencies:
- debug "^4.1.1"
- sax "^1.2.4"
-
builder-util-runtime@8.7.3:
version "8.7.3"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.7.3.tgz#0aaafa52d25295c939496f62231ca9ff06c30e40"
@@ -4972,17 +4976,17 @@ electron-publish@22.10.5:
mime "^2.5.0"
electron-updater@^4.3.1:
- version "4.3.1"
- resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-4.3.1.tgz#9d485b6262bc56fcf7ee62b1dc1b3b105a3e96a7"
- integrity sha512-UDC5AHCgeiHJYDYWZG/rsl1vdAFKqI/Lm7whN57LKAk8EfhTewhcEHzheRcncLgikMcQL8gFo1KeX51tf5a5Wg==
+ version "4.3.8"
+ resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-4.3.8.tgz#94f1731682a756385726183e2b04b959cb319456"
+ integrity sha512-/tB82Ogb2LqaXrUzAD8waJC+TZV52Pr0Znfj7w+i4D+jA2GgrKFI3Pxjp+36y9FcBMQz7kYsMHcB6c5zBJao+A==
dependencies:
- "@types/semver" "^7.1.0"
- builder-util-runtime "8.7.0"
- fs-extra "^9.0.0"
- js-yaml "^3.13.1"
+ "@types/semver" "^7.3.4"
+ builder-util-runtime "8.7.3"
+ fs-extra "^9.1.0"
+ js-yaml "^4.0.0"
lazy-val "^1.0.4"
lodash.isequal "^4.5.0"
- semver "^7.1.3"
+ semver "^7.3.4"
electron-window-state@^5.0.3:
version "5.0.3"
@@ -6526,11 +6530,16 @@ got@^9.6.0:
to-readable-stream "^1.0.0"
url-parse-lax "^3.0.0"
-graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4:
+graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.2.2, graceful-fs@^4.2.4:
version "4.2.4"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
+graceful-fs@^4.1.6, graceful-fs@^4.2.0:
+ version "4.2.6"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee"
+ integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==
+
"graceful-readlink@>= 1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
@@ -8215,7 +8224,15 @@ js-sha3@^0.8.0:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
-js-yaml@^3.13.1, js-yaml@^3.14.0:
+js-yaml@^3.13.1:
+ version "3.14.1"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
+ integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
+ dependencies:
+ argparse "^1.0.7"
+ esprima "^4.0.0"
+
+js-yaml@^3.14.0:
version "3.14.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482"
integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==
@@ -8371,11 +8388,11 @@ jsonfile@^4.0.0:
graceful-fs "^4.1.6"
jsonfile@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179"
- integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
+ integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
dependencies:
- universalify "^1.0.0"
+ universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
@@ -12330,7 +12347,7 @@ semver-diff@^3.1.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
-semver@7.x, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2:
+semver@7.x, semver@^7.2.1, semver@^7.3.2:
version "7.3.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
@@ -13650,7 +13667,7 @@ ts-essentials@^4.0.0:
resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-4.0.0.tgz#506c42b270bbd0465574b90416533175b09205ab"
integrity sha512-uQJX+SRY9mtbKU+g9kl5Fi7AEMofPCvHfJkQlaygpPmHPZrtgaBqbWFOYyiA47RhnSwwnXdepUJrgqUYxoUyhQ==
-ts-jest@^26.1.0:
+ts-jest@26.3.0:
version "26.3.0"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.3.0.tgz#6b2845045347dce394f069bb59358253bc1338a9"
integrity sha512-Jq2uKfx6bPd9+JDpZNMBJMdMQUC3sJ08acISj8NXlVgR2d5OqslEHOR2KHMgwymu8h50+lKIm0m0xj/ioYdW2Q==
@@ -13945,11 +13962,6 @@ universalify@^0.1.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
-universalify@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d"
- integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==
-
universalify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"