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 ac6e94bfd8..528f74c084 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-beta.1",
+ "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 2a346bfef5..808092c89d 100644
--- a/src/common/__tests__/kube-helpers.test.ts
+++ b/src/common/__tests__/kube-helpers.test.ts
@@ -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();
});
});
});
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 28afa55bac..bdb5ed5bc4 100644
--- a/src/common/catalog/catalog-entity-registry.ts
+++ b/src/common/catalog/catalog-entity-registry.ts
@@ -1,10 +1,15 @@
-import { action, computed, observable, IObservableArray } from "mobx";
+import { action, computed, observable, IComputedValue, IObservableArray } from "mobx";
import { CatalogEntity } from "./catalog-entity";
+import { iter } from "../utils";
export class CatalogEntityRegistry {
- protected sources = observable.map>([], { deep: true });
+ protected sources = observable.map>([], { deep: true });
- @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);
}
@@ -13,7 +18,7 @@ export class CatalogEntityRegistry {
}
@computed get items(): CatalogEntity[] {
- return 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 fdd443ae54..2062347f4a 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 57cf900f57..c5ebb45c85 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 {
@@ -259,18 +267,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/kube-helpers.ts b/src/common/kube-helpers.ts
index e4a1168699..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
@@ -181,42 +206,47 @@ export function getNodeWarningConditions(node: V1Node) {
/**
* 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 1118344d02..ab07838f9b 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,8 +52,6 @@ export class UserStore extends BaseStore {
configName: "lens-user-store",
migrations,
});
-
- this.handleOnLoad();
}
@observable lastSeenAppVersion = "0.0.0";
@@ -71,14 +77,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
@@ -99,16 +122,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);
}
@@ -171,7 +184,7 @@ export class UserStore extends BaseStore {
this.localeTimezone = tz;
}
- protected refreshNewContexts = async () => {
+ protected async refreshNewContexts() {
try {
const kubeConfig = await readFile(this.kubeConfigPath, "utf8");
@@ -186,7 +199,7 @@ export class UserStore extends BaseStore {
logger.error(err);
this.resetKubeConfigPath();
}
- };
+ }
@action
markNewContextsAsSeen() {
@@ -225,37 +238,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 edb2b44d96..d4467db958 100644
--- a/src/common/utils/index.ts
+++ b/src/common/utils/index.ts
@@ -11,10 +11,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/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 73b9b9d580..8bc028cbd7 100644
--- a/src/main/catalog-pusher.ts
+++ b/src/main/catalog-pusher.ts
@@ -1,7 +1,8 @@
-import { autorun, toJS } from "mobx";
+import { reaction, toJS } from "mobx";
import { broadcastMessage, subscribeToBroadcast, unsubscribeFromBroadcast } from "../common/ipc";
import { CatalogEntityRegistry} from "../common/catalog";
import "../common/catalog-entities/kubernetes-cluster";
+import { Disposer } from "../common/utils";
export class CatalogPusher {
static init(catalog: CatalogEntityRegistry) {
@@ -11,22 +12,20 @@ export class CatalogPusher {
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, { recurseEverything: true }));
- }
}
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 c1485881e4..369c4f61b9 100644
--- a/src/main/cluster-manager.ts
+++ b/src/main/cluster-manager.ts
@@ -18,7 +18,7 @@ export class ClusterManager extends Singleton {
constructor(public readonly port: number) {
super();
- 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) => {
@@ -59,17 +59,17 @@ export class ClusterManager extends Singleton {
}
@action protected updateCatalogSource(clusters: Cluster[]) {
- this.catalogSource.forEach((entity, index) => {
- const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id);
+ 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);
@@ -84,11 +84,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) {
@@ -104,7 +108,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;
@@ -114,32 +118,7 @@ export class ClusterManager extends Singleton {
active: !cluster.disconnected
};
}
- });
- }
-
- protected 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.metadata["distribution"] || "unknown").toString()
- }
- },
- spec: {
- kubeconfigPath: cluster.kubeConfigPath,
- kubeconfigContext: cluster.contextName
- },
- status: {
- phase: cluster.disconnected ? "disconnected" : "connected",
- reason: "",
- message: "",
- active: !cluster.disconnected
- }
- }));
+ }
}
protected onNetworkOffline() {
@@ -192,3 +171,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 6606a71605..d55a243f99 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, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
@@ -57,7 +57,7 @@ export interface ClusterState {
*/
export class Cluster implements ClusterModel, ClusterState {
/** Unique id for a cluster */
- public id: ClusterId;
+ public readonly id: ClusterId;
/**
* Kubectl
*
@@ -85,7 +85,7 @@ export class Cluster implements ClusterModel, ClusterState {
whenReady = when(() => this.ready);
/**
- * Is cluster object initializinng on-going
+ * Is cluster object initializing on-going
*
* @observable
*/
@@ -231,6 +231,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) {
+ this.id = model.id;
this.updateModel(model);
try {
const kubeconfig = this.getKubeconfig();
+ const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
+
+ if (error) {
+ throw error;
+ }
- validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
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 42f965fe89..2b4a7ae911 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";
const workingDir = path.join(app.getPath("appData"), appName);
@@ -131,6 +132,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");
@@ -237,6 +241,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/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 4b2d72ea85..c542c86d43 100644
--- a/src/renderer/api/catalog-entity-registry.ts
+++ b/src/renderer/api/catalog-entity-registry.ts
@@ -17,27 +17,7 @@ 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);
-
- if (foundIndex === -1) {
- this._items.splice(index, 1);
- }
- });
-
- 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);
- }
- });
+ this._items = items.map(data => this.categoryRegistry.getEntityForData(data));
}
set activeEntity(entity: CatalogEntity) {
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 8be97cc86d..43b1c170eb 100644
--- a/src/renderer/bootstrap.tsx
+++ b/src/renderer/bootstrap.tsx
@@ -21,6 +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 { DefaultProps } from "./mui-base-theme";
/**
* If this is a development buid, wait a second to attach
@@ -92,7 +93,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 9a662636b0..4c65104c7e 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";
@@ -132,36 +132,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);
@@ -204,7 +196,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 9b9814314f..22ef59df15 100644
--- a/src/renderer/components/+catalog/catalog-add-button.tsx
+++ b/src/renderer/components/+catalog/catalog-add-button.tsx
@@ -74,6 +74,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 d101d614ba..ea529320b8 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",
@@ -209,7 +210,6 @@ export class Preferences extends React.Component {
)}
-
{this.activeTab == Pages.Kubernetes && (
@@ -217,20 +217,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/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 ae1517d79f..35f052d03f 100644
--- a/src/renderer/kube-object.store.ts
+++ b/src/renderer/kube-object.store.ts
@@ -1,17 +1,19 @@
import type { ClusterContext } from "./components/context";
import { action, computed, observable, reaction, when } from "mobx";
-import { autobind } from "./utils";
+import { autobind, 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;
}
@autobind()
@@ -21,9 +23,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 +106,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 +119,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 +137,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 +145,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 +161,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 +281,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 +304,39 @@ 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);
+ } else if (error) { // not sure what to do, best to retry
+ clearTimeout(timedRetry);
+ timedRetry = setTimeout(watch, 5000);
}
if (data) {
@@ -342,11 +344,8 @@ export abstract class KubeObjectStore extends ItemSt
}
};
- api.watch({
- namespace,
- abortController,
- callback: (data, error) => callback(data, error)
- });
+ signal.addEventListener("abort", () => clearTimeout(timedRetry));
+ watch();
}
@action
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/yarn.lock b/yarn.lock
index db1969ce97..e5443a82e2 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"
@@ -13660,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==