1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Release/v5.3.1 (#4465)

Co-authored-by: Roman <ixrock@gmail.com>
Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
Co-authored-by: Juho Heikka <juho.heikka@gmail.com>
Co-authored-by: Jan Jansen <farodin91@users.noreply.github.com>
This commit is contained in:
Sebastian Malton 2021-11-30 17:10:13 -05:00 committed by GitHub
parent a007cbd9ce
commit 63256dcaf9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 635 additions and 461 deletions

View File

@ -3,7 +3,7 @@
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens", "homepage": "https://github.com/lensapp/lens",
"version": "5.3.0", "version": "5.3.1",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
"license": "MIT", "license": "MIT",
@ -215,8 +215,8 @@
"mac-ca": "^1.0.6", "mac-ca": "^1.0.6",
"marked": "^2.1.3", "marked": "^2.1.3",
"md5-file": "^5.0.0", "md5-file": "^5.0.0",
"mobx": "^6.3.0", "mobx": "^6.3.7",
"mobx-observable-history": "^2.0.1", "mobx-observable-history": "^2.0.3",
"mobx-react": "^7.2.1", "mobx-react": "^7.2.1",
"mock-fs": "^4.14.0", "mock-fs": "^4.14.0",
"moment": "^2.29.1", "moment": "^2.29.1",

View File

@ -23,7 +23,7 @@ import path from "path";
import Config from "conf"; import Config from "conf";
import type { Options as ConfOptions } from "conf/dist/source/types"; import type { Options as ConfOptions } from "conf/dist/source/types";
import { ipcMain, ipcRenderer } from "electron"; import { ipcMain, ipcRenderer } from "electron";
import { IReactionOptions, makeObservable, reaction, runInAction } from "mobx"; import { IEqualsComparer, makeObservable, reaction, runInAction } from "mobx";
import { getAppVersion, Singleton, toJS, Disposer } from "./utils"; import { getAppVersion, Singleton, toJS, Disposer } from "./utils";
import logger from "../main/logger"; import logger from "../main/logger";
import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc"; import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc";
@ -33,7 +33,10 @@ import { kebabCase } from "lodash";
import { AppPaths } from "./app-paths"; import { AppPaths } from "./app-paths";
export interface BaseStoreParams<T> extends ConfOptions<T> { export interface BaseStoreParams<T> extends ConfOptions<T> {
syncOptions?: IReactionOptions; syncOptions?: {
fireImmediately?: boolean;
equals?: IEqualsComparer<T>;
};
} }
/** /**

View File

@ -29,7 +29,6 @@ export default function configurePackages() {
// Docs: https://mobx.js.org/configuration.html // Docs: https://mobx.js.org/configuration.html
Mobx.configure({ Mobx.configure({
enforceActions: "never", enforceActions: "never",
isolateGlobalState: true,
// TODO: enable later (read more: https://mobx.js.org/migrating-from-4-or-5.html) // TODO: enable later (read more: https://mobx.js.org/migrating-from-4-or-5.html)
// computedRequiresReaction: true, // computedRequiresReaction: true,

View File

@ -230,6 +230,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
return index; return index;
} }
@action
restackItems(from: number, to: number): void { restackItems(from: number, to: number): void {
const { items } = this.getActive(); const { items } = this.getActive();
const source = items[from]; const source = items[from];

View File

@ -25,6 +25,7 @@ import { KubeJsonApi } from "../kube-json-api";
import { KubeObject } from "../kube-object"; import { KubeObject } from "../kube-object";
import AbortController from "abort-controller"; import AbortController from "abort-controller";
import { delay } from "../../utils/delay"; import { delay } from "../../utils/delay";
import { PassThrough } from "stream";
class TestKubeObject extends KubeObject { class TestKubeObject extends KubeObject {
static kind = "Pod"; static kind = "Pod";
@ -133,7 +134,10 @@ describe("KubeApi", () => {
checkPreferredVersion: true, checkPreferredVersion: true,
}); });
await kubeApi.get(); await kubeApi.get({
name: "foo",
namespace: "default",
});
expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiPrefix).toEqual("/apis");
expect(kubeApi.apiGroup).toEqual("networking.k8s.io"); expect(kubeApi.apiGroup).toEqual("networking.k8s.io");
}); });
@ -167,13 +171,15 @@ describe("KubeApi", () => {
const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses";
const kubeApi = new KubeApi({ const kubeApi = new KubeApi({
request, request,
objectConstructor: KubeObject, objectConstructor: Object.assign(KubeObject, { apiBase }),
apiBase,
fallbackApiBases: [fallbackApiBase], fallbackApiBases: [fallbackApiBase],
checkPreferredVersion: true, checkPreferredVersion: true,
}); });
await kubeApi.get(); await kubeApi.get({
name: "foo",
namespace: "default",
});
expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiPrefix).toEqual("/apis");
expect(kubeApi.apiGroup).toEqual("extensions"); expect(kubeApi.apiGroup).toEqual("extensions");
}); });
@ -298,19 +304,28 @@ describe("KubeApi", () => {
describe("watch", () => { describe("watch", () => {
let api: TestKubeApi; let api: TestKubeApi;
let stream: PassThrough;
beforeEach(() => { beforeEach(() => {
api = new TestKubeApi({ api = new TestKubeApi({
request, request,
objectConstructor: TestKubeObject, objectConstructor: TestKubeObject,
}); });
stream = new PassThrough();
});
afterEach(() => {
stream.end();
stream.destroy();
}); });
it("sends a valid watch request", () => { it("sends a valid watch request", () => {
const spy = jest.spyOn(request, "getResponse"); const spy = jest.spyOn(request, "getResponse");
(fetch as any).mockResponse(async () => { (fetch as any).mockResponse(async () => {
return {}; return {
body: stream,
};
}); });
api.watch({ namespace: "kube-system" }); api.watch({ namespace: "kube-system" });
@ -321,7 +336,9 @@ describe("KubeApi", () => {
const spy = jest.spyOn(request, "getResponse"); const spy = jest.spyOn(request, "getResponse");
(fetch as any).mockResponse(async () => { (fetch as any).mockResponse(async () => {
return {}; return {
body: stream,
};
}); });
api.watch({ namespace: "kube-system", timeout: 60 }); api.watch({ namespace: "kube-system", timeout: 60 });
@ -336,7 +353,9 @@ describe("KubeApi", () => {
done(); done();
}); });
return {}; return {
body: stream,
};
}); });
const abortController = new AbortController(); const abortController = new AbortController();
@ -358,20 +377,22 @@ describe("KubeApi", () => {
it("if request ended", (done) => { it("if request ended", (done) => {
const spy = jest.spyOn(request, "getResponse"); const spy = jest.spyOn(request, "getResponse");
// we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely jest.spyOn(stream, "on").mockImplementation((eventName: string, callback: Function) => {
jest.spyOn(global, "fetch").mockImplementation(async () => {
return {
ok: true,
body: {
on: (eventName: string, callback: Function) => {
// End the request in 100ms. // End the request in 100ms.
if (eventName === "end") { if (eventName === "end") {
setTimeout(() => { setTimeout(() => {
callback(); callback();
}, 100); }, 100);
} }
},
}, return stream;
});
// we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely
jest.spyOn(global, "fetch").mockImplementation(async () => {
return {
ok: true,
body: stream,
} as any; } as any;
}); });
@ -391,7 +412,9 @@ describe("KubeApi", () => {
const spy = jest.spyOn(request, "getResponse"); const spy = jest.spyOn(request, "getResponse");
(fetch as any).mockResponse(async () => { (fetch as any).mockResponse(async () => {
return {}; return {
body: stream,
};
}); });
const timeoutSeconds = 1; const timeoutSeconds = 1;
@ -412,20 +435,22 @@ describe("KubeApi", () => {
it("retries only once if request ends and timeout is set", (done) => { it("retries only once if request ends and timeout is set", (done) => {
const spy = jest.spyOn(request, "getResponse"); const spy = jest.spyOn(request, "getResponse");
// we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely jest.spyOn(stream, "on").mockImplementation((eventName: string, callback: Function) => {
jest.spyOn(global, "fetch").mockImplementation(async () => { // End the request in 100ms.
return {
ok: true,
body: {
on: (eventName: string, callback: Function) => {
// End the request in 100ms
if (eventName === "end") { if (eventName === "end") {
setTimeout(() => { setTimeout(() => {
callback(); callback();
}, 100); }, 100);
} }
},
}, return stream;
});
// we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely
jest.spyOn(global, "fetch").mockImplementation(async () => {
return {
ok: true,
body: stream,
} as any; } as any;
}); });

View File

@ -25,4 +25,5 @@ export interface ClusterContext {
cluster?: Cluster; cluster?: Cluster;
allNamespaces: string[]; // available / allowed namespaces from cluster.ts allNamespaces: string[]; // available / allowed namespaces from cluster.ts
contextNamespaces: string[]; // selected by user (see: namespace-select.tsx) contextNamespaces: string[]; // selected by user (see: namespace-select.tsx)
hasSelectedAll: boolean;
} }

View File

@ -21,7 +21,6 @@
// Base class for building all kubernetes apis // Base class for building all kubernetes apis
import merge from "lodash/merge";
import { isFunction } from "lodash"; import { isFunction } from "lodash";
import { stringify } from "querystring"; import { stringify } from "querystring";
import { apiKubePrefix, isDevelopment } from "../../common/vars"; import { apiKubePrefix, isDevelopment } from "../../common/vars";
@ -221,6 +220,29 @@ const patchTypeHeaders: Record<KubeApiPatchType, string> = {
"strategic": "application/strategic-merge-patch+json", "strategic": "application/strategic-merge-patch+json",
}; };
export interface ResourceDescriptor {
/**
* The name of the kubernetes resource
*/
name: string;
/**
* The namespace that the resource lives in (if the resource is namespaced)
*
* Note: if not provided and the resource kind is namespaced, then this defaults to `"default"`
*/
namespace?: string;
}
export interface DeleteResourceDescriptor extends ResourceDescriptor {
/**
* This determinines how child resources should be handled by kubernetes
*
* @default "Background"
*/
propagationPolicy?: PropagationPolicy;
}
export class KubeApi<T extends KubeObject> { export class KubeApi<T extends KubeObject> {
readonly kind: string; readonly kind: string;
readonly apiBase: string; readonly apiBase: string;
@ -273,14 +295,18 @@ export class KubeApi<T extends KubeObject> {
*/ */
private async getLatestApiPrefixGroup() { private async getLatestApiPrefixGroup() {
// Note that this.options.apiBase is the "full" url, whereas this.apiBase is parsed // Note that this.options.apiBase is the "full" url, whereas this.apiBase is parsed
const apiBases = [this.options.apiBase, ...this.options.fallbackApiBases]; const apiBases = [this.options.apiBase, this.objectConstructor.apiBase, ...this.options.fallbackApiBases];
for (const apiUrl of apiBases) { for (const apiUrl of apiBases) {
if (!apiUrl) {
continue;
}
try {
// Split e.g. "/apis/extensions/v1beta1/ingresses" to parts // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts
const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl); const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl);
// Request available resources // Request available resources
try {
const response = await this.request.get<IKubeResourceList>(`${apiPrefix}/${apiVersionWithGroup}`); const response = await this.request.get<IKubeResourceList>(`${apiPrefix}/${apiVersionWithGroup}`);
// If the resource is found in the group, use this apiUrl // If the resource is found in the group, use this apiUrl
@ -304,7 +330,7 @@ export class KubeApi<T extends KubeObject> {
return await this.getLatestApiPrefixGroup(); return await this.getLatestApiPrefixGroup();
} catch (error) { } catch (error) {
// If valid API wasn't found, log the error and return defaults below // If valid API wasn't found, log the error and return defaults below
logger.error(error); logger.error(`[KUBE-API]: ${error}`);
} }
} }
@ -355,12 +381,12 @@ export class KubeApi<T extends KubeObject> {
return this.list(params, { limit: 1 }); return this.list(params, { limit: 1 });
} }
getUrl({ name = "", namespace = "" } = {}, query?: Partial<IKubeApiQueryParams>) { getUrl({ name, namespace = "default" }: Partial<ResourceDescriptor> = {}, query?: Partial<IKubeApiQueryParams>) {
const resourcePath = createKubeApiURL({ const resourcePath = createKubeApiURL({
apiPrefix: this.apiPrefix, apiPrefix: this.apiPrefix,
apiVersion: this.apiVersionWithGroup, apiVersion: this.apiVersionWithGroup,
resource: this.apiResource, resource: this.apiResource,
namespace: this.isNamespaced ? namespace : undefined, namespace: this.isNamespaced ? namespace ?? "default" : undefined,
name, name,
}); });
@ -438,10 +464,10 @@ export class KubeApi<T extends KubeObject> {
throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`);
} }
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T | null> { async get(desc: ResourceDescriptor, query?: IKubeApiQueryParams): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const url = this.getUrl({ namespace, name }); const url = this.getUrl(desc);
const res = await this.request.get(url, { query }); const res = await this.request.get(url, { query });
const parsed = this.parseResponse(res); const parsed = this.parseResponse(res);
@ -452,19 +478,18 @@ export class KubeApi<T extends KubeObject> {
return parsed; return parsed;
} }
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> { async create({ name, namespace }: Partial<ResourceDescriptor>, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace }); const apiUrl = this.getUrl({ namespace });
const res = await this.request.post(apiUrl, { const res = await this.request.post(apiUrl, {
data: merge({ data: {
kind: this.kind, ...data,
apiVersion: this.apiVersionWithGroup,
metadata: { metadata: {
name, name,
namespace, namespace,
}, },
}, data), },
}); });
const parsed = this.parseResponse(res); const parsed = this.parseResponse(res);
@ -475,11 +500,19 @@ export class KubeApi<T extends KubeObject> {
return parsed; return parsed;
} }
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> { async update({ name, namespace }: ResourceDescriptor, data: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name }); const apiUrl = this.getUrl({ namespace, name });
const res = await this.request.put(apiUrl, { data }); const res = await this.request.put(apiUrl, {
data: {
...data,
metadata: {
name,
namespace,
},
},
});
const parsed = this.parseResponse(res); const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
@ -489,9 +522,9 @@ export class KubeApi<T extends KubeObject> {
return parsed; return parsed;
} }
async patch({ name = "", namespace = "default" } = {}, data?: Partial<T> | Patch, strategy: KubeApiPatchType = "strategic"): Promise<T | null> { async patch(desc: ResourceDescriptor, data?: Partial<T> | Patch, strategy: KubeApiPatchType = "strategic"): Promise<T | null> {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name }); const apiUrl = this.getUrl(desc);
const res = await this.request.patch(apiUrl, { data }, { const res = await this.request.patch(apiUrl, { data }, {
headers: { headers: {
@ -507,16 +540,15 @@ export class KubeApi<T extends KubeObject> {
return parsed; return parsed;
} }
async delete({ name = "", namespace = "default", propagationPolicy = "Background" }: { name: string, namespace?: string, propagationPolicy?: PropagationPolicy }) { async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) {
await this.checkPreferredVersion(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name }); const apiUrl = this.getUrl(desc);
const reqInit = {
return this.request.del(apiUrl, {
query: { query: {
propagationPolicy, propagationPolicy,
}, },
}; });
return this.request.del(apiUrl, reqInit);
} }
getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) { getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) {

View File

@ -33,9 +33,8 @@ import type { RequestInit } from "node-fetch";
import AbortController from "abort-controller"; import AbortController from "abort-controller";
import type { Patch } from "rfc6902"; import type { Patch } from "rfc6902";
export interface KubeObjectStoreLoadingParams<K extends KubeObject> { export interface KubeObjectStoreLoadingParams {
namespaces: string[]; namespaces: string[];
api?: KubeApi<K>;
reqInit?: RequestInit; reqInit?: RequestInit;
/** /**
@ -63,6 +62,11 @@ export interface KubeObjectStoreSubscribeParams {
* being rejected with * being rejected with
*/ */
onLoadFailure?: (err: any) => void; onLoadFailure?: (err: any) => void;
/**
* An optional parent abort controller
*/
abortController?: AbortController;
} }
export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T> { export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T> {
@ -167,8 +171,8 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
} }
protected async loadItems({ namespaces, api, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams<T>): Promise<T[]> { protected async loadItems({ namespaces, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams): Promise<T[]> {
if (!this.context?.cluster.isAllowedResource(api.kind)) { if (!this.context?.cluster.isAllowedResource(this.api.kind)) {
return []; return [];
} }
@ -176,12 +180,12 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
&& this.context.cluster.accessibleNamespaces.length === 0 && this.context.cluster.accessibleNamespaces.length === 0
&& this.context.allNamespaces.every(ns => namespaces.includes(ns)); && this.context.allNamespaces.every(ns => namespaces.includes(ns));
if (!api.isNamespaced || isLoadingAll) { if (!this.api.isNamespaced || isLoadingAll) {
if (api.isNamespaced) { if (this.api.isNamespaced) {
this.loadedNamespaces = []; this.loadedNamespaces = [];
} }
const res = api.list({ reqInit }, this.query); const res = this.api.list({ reqInit }, this.query);
if (onLoadFailure) { if (onLoadFailure) {
try { try {
@ -203,7 +207,7 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
this.loadedNamespaces = namespaces; this.loadedNamespaces = namespaces;
const results = await Promise.allSettled( const results = await Promise.allSettled(
namespaces.map(namespace => api.list({ namespace, reqInit }, this.query)), namespaces.map(namespace => this.api.list({ namespace, reqInit }, this.query)),
); );
const res: T[] = []; const res: T[] = [];
@ -215,7 +219,7 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
case "rejected": case "rejected":
if (onLoadFailure) { if (onLoadFailure) {
onLoadFailure(result.reason.message); onLoadFailure(result.reason.message || result.reason);
} else { } else {
// if onLoadFailure is not provided then preserve old behaviour // if onLoadFailure is not provided then preserve old behaviour
throw result.reason; throw result.reason;
@ -231,24 +235,14 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
@action @action
async loadAll(options: KubeObjectStoreLoadAllParams = {}): Promise<void | T[]> { async loadAll({ namespaces = this.context.contextNamespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise<void | T[]> {
await this.contextReady; await this.contextReady;
this.isLoading = true; this.isLoading = true;
const {
namespaces = this.context.allNamespaces, // load all namespaces by default
merge = true, // merge loaded items or return as result
reqInit,
onLoadFailure,
} = options;
try { try {
const items = await this.loadItems({ namespaces, api: this.api, reqInit, onLoadFailure }); const items = await this.loadItems({ namespaces, reqInit, onLoadFailure });
if (merge) { this.mergeItems(items, { merge });
this.mergeItems(items, { replace: false });
} else {
this.mergeItems(items, { replace: true });
}
this.isLoaded = true; this.isLoaded = true;
this.failedLoading = false; this.failedLoading = false;
@ -275,11 +269,11 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
@action @action
protected mergeItems(partialItems: T[], { replace = false, updateStore = true, sort = true, filter = true } = {}): T[] { protected mergeItems(partialItems: T[], { merge = true, updateStore = true, sort = true, filter = true } = {}): T[] {
let items = partialItems; let items = partialItems;
// update existing items // update existing items
if (!replace) { if (merge) {
const namespaces = partialItems.map(item => item.getNs()); const namespaces = partialItems.map(item => item.getNs());
items = [ items = [
@ -369,7 +363,8 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
return this.postUpdate( return this.postUpdate(
await this.api.update( await this.api.update(
{ {
name: item.getName(), namespace: item.getNs(), name: item.getName(),
namespace: item.getNs(),
}, },
data, data,
), ),
@ -394,23 +389,21 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
}); });
} }
subscribe(opts: KubeObjectStoreSubscribeParams = {}) { subscribe({ onLoadFailure, abortController = new AbortController() }: KubeObjectStoreSubscribeParams = {}) {
const abortController = new AbortController();
if (this.api.isNamespaced) { if (this.api.isNamespaced) {
Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])])
.then(() => { .then(() => {
if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
return this.watchNamespace("", abortController, opts); return this.watchNamespace("", abortController, { onLoadFailure });
} }
for (const namespace of this.loadedNamespaces) { for (const namespace of this.loadedNamespaces) {
this.watchNamespace(namespace, abortController, opts); this.watchNamespace(namespace, abortController, { onLoadFailure });
} }
}) })
.catch(noop); // ignore DOMExceptions .catch(noop); // ignore DOMExceptions
} else { } else {
this.watchNamespace("", abortController, opts); this.watchNamespace("", abortController, { onLoadFailure });
} }
return () => abortController.abort(); return () => abortController.abort();

View File

@ -105,8 +105,9 @@ export class KubeCreationError extends Error {
} }
export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = any, Spec = any> implements ItemObject { export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = any, Spec = any> implements ItemObject {
static readonly kind: string; static readonly kind?: string;
static readonly namespaced: boolean; static readonly namespaced?: boolean;
static readonly apiBase?: string;
apiVersion: string; apiVersion: string;
kind: string; kind: string;

View File

@ -25,32 +25,36 @@
import type { KubeObjectStore } from "./kube-object.store"; import type { KubeObjectStore } from "./kube-object.store";
import type { ClusterContext } from "./cluster-context"; import type { ClusterContext } from "./cluster-context";
import plimit from "p-limit"; import { comparer, reaction } from "mobx";
import { comparer, observable, reaction, makeObservable } from "mobx"; import { disposer, Disposer, noop } from "../utils";
import { autoBind, disposer, Disposer, noop } from "../utils";
import type { KubeApi } from "./kube-api";
import type { KubeJsonApiData } from "./kube-json-api"; import type { KubeJsonApiData } from "./kube-json-api";
import { isDebugging, isProduction } from "../vars";
import type { KubeObject } from "./kube-object"; import type { KubeObject } from "./kube-object";
import AbortController from "abort-controller";
import { once } from "lodash";
import logger from "../logger";
class WrappedAbortController extends AbortController {
constructor(protected parent: AbortController) {
super();
parent.signal.addEventListener("abort", () => {
this.abort();
});
}
}
export interface IKubeWatchEvent<T extends KubeJsonApiData> { export interface IKubeWatchEvent<T extends KubeJsonApiData> {
type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR";
object?: T; object?: T;
} }
interface KubeWatchPreloadOptions { export interface KubeWatchSubscribeStoreOptions {
/** /**
* The namespaces to watch * The namespaces to watch
* @default all-accessible * @default all selected namespaces
*/ */
namespaces?: string[]; namespaces?: string[];
/**
* Whether to skip loading if the store is already loaded
* @default false
*/
loadOnce?: boolean;
/** /**
* A function that is called when listing fails. If set then blocks errors * A function that is called when listing fails. If set then blocks errors
* being rejected with * being rejected with
@ -58,123 +62,148 @@ interface KubeWatchPreloadOptions {
onLoadFailure?: (err: any) => void; onLoadFailure?: (err: any) => void;
} }
export interface KubeWatchSubscribeStoreOptions extends KubeWatchPreloadOptions {
/**
* Whether to subscribe only after loading all stores
* @default true
*/
waitUntilLoaded?: boolean;
/**
* Whether to preload the stores before watching
* @default true
*/
preload?: boolean;
}
export interface IKubeWatchLog { export interface IKubeWatchLog {
message: string | string[] | Error; message: string | string[] | Error;
meta?: object; meta?: object;
cssStyle?: string; cssStyle?: string;
} }
interface SubscribeStoreParams {
store: KubeObjectStore<KubeObject>;
parent: AbortController;
watchChanges: boolean;
namespaces: string[];
onLoadFailure?: (err: any) => void;
}
class WatchCount {
#data = new Map<KubeObjectStore<KubeObject>, number>();
public inc(store: KubeObjectStore<KubeObject>): number {
if (!this.#data.has(store)) {
this.#data.set(store, 0);
}
const newCount = this.#data.get(store) + 1;
logger.info(`[KUBE-WATCH-API]: inc() count for ${store.api.objectConstructor.apiBase} is now ${newCount}`);
this.#data.set(store, newCount);
return newCount;
}
public dec(store: KubeObjectStore<KubeObject>): number {
if (!this.#data.has(store)) {
throw new Error(`Cannot dec count for store that has never been inc: ${store.api.objectConstructor.kind}`);
}
const newCount = this.#data.get(store) - 1;
if (newCount < 0) {
throw new Error(`Cannot dec count more times than it has been inc: ${store.api.objectConstructor.kind}`);
}
logger.debug(`[KUBE-WATCH-API]: dec() count for ${store.api.objectConstructor.apiBase} is now ${newCount}`);
this.#data.set(store, newCount);
return newCount;
}
}
export class KubeWatchApi { export class KubeWatchApi {
@observable context: ClusterContext = null; static context: ClusterContext = null;
constructor() { #watch = new WatchCount();
makeObservable(this);
autoBind(this); private subscribeStore({ store, parent, watchChanges, namespaces, onLoadFailure }: SubscribeStoreParams): Disposer {
if (this.#watch.inc(store) > 1) {
// don't load or subscribe to a store more than once
return () => this.#watch.dec(store);
} }
isAllowedApi(api: KubeApi<KubeObject>): boolean { let childController = new WrappedAbortController(parent);
return Boolean(this.context?.cluster.isAllowedResource(api.kind)); const unsubscribe = disposer();
const loadThenSubscribe = async (namespaces: string[]) => {
try {
await store.loadAll({ namespaces, reqInit: { signal: childController.signal }, onLoadFailure });
unsubscribe.push(store.subscribe({ onLoadFailure, abortController: childController }));
} catch (error) {
if (!(error instanceof DOMException)) {
this.log(Object.assign(new Error("Loading stores has failed"), { cause: error }), {
meta: { store, namespaces },
});
} }
preloadStores(stores: KubeObjectStore<KubeObject>[], { loadOnce, namespaces, onLoadFailure }: KubeWatchPreloadOptions = {}) {
const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages
const preloading: Promise<any>[] = [];
for (const store of stores) {
preloading.push(limitRequests(async () => {
if (store.isLoaded && loadOnce) return; // skip
return store.loadAll({ namespaces, onLoadFailure });
}));
} }
return {
loading: Promise.allSettled(preloading),
cancelLoading: () => limitRequests.clearQueue(),
};
}
subscribeStores(stores: KubeObjectStore<KubeObject>[], opts: KubeWatchSubscribeStoreOptions = {}): Disposer {
const { preload = true, waitUntilLoaded = true, loadOnce = false, onLoadFailure } = opts;
const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? [];
const unsubscribeStores = disposer();
let isUnsubscribed = false;
const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce, onLoadFailure });
let preloading = preload && load();
let cancelReloading: Disposer = noop;
const subscribe = () => {
if (isUnsubscribed) {
return;
}
unsubscribeStores.push(...stores.map(store => store.subscribe({ onLoadFailure })));
}; };
if (preloading) { /**
if (waitUntilLoaded) { * We don't want to wait because we want to start reacting to namespace
preloading.loading.then(subscribe, error => { * selection changes ASAP
this.log({ */
message: new Error("Loading stores has failed"), loadThenSubscribe(namespaces).catch(noop);
meta: { stores, error, options: opts },
}); const cancelReloading = watchChanges
}); ? reaction(
} else { // Note: must slice because reaction won't fire if it isn't there
subscribe(); () => [KubeWatchApi.context.contextNamespaces.slice(), KubeWatchApi.context.hasSelectedAll] as const,
([namespaces, curSelectedAll], [prevNamespaces, prevSelectedAll]) => {
if (curSelectedAll && prevSelectedAll) {
const action = namespaces.length > prevNamespaces.length ? "created" : "deleted";
return console.debug(`[KUBE-WATCH-API]: Not changing watch for ${store.api.apiBase} because a new namespace was ${action} but all namespaces are selected`);
} }
// reload stores only for context namespaces change console.log(`[KUBE-WATCH-API]: changing watch ${store.api.apiBase}`, namespaces);
cancelReloading = reaction(() => this.context?.contextNamespaces, namespaces => { childController.abort();
preloading?.cancelLoading(); unsubscribe();
unsubscribeStores(); childController = new WrappedAbortController(parent);
preloading = load(namespaces); loadThenSubscribe(namespaces).catch(noop);
preloading.loading.then(subscribe); },
}, { {
equals: comparer.shallow, equals: comparer.shallow,
}); },
)
: noop; // don't watch namespaces if namespaces were provided
return () => {
if (this.#watch.dec(store) === 0) {
// only stop the subcribe if this is the last one
cancelReloading();
childController.abort();
unsubscribe();
} }
};
}
subscribeStores(stores: KubeObjectStore<KubeObject>[], { namespaces, onLoadFailure }: KubeWatchSubscribeStoreOptions = {}): Disposer {
const parent = new AbortController();
const unsubscribe = disposer(
...stores.map(store => this.subscribeStore({
store,
parent,
watchChanges: !namespaces && store.api.isNamespaced,
namespaces: namespaces ?? KubeWatchApi.context?.contextNamespaces ?? [],
onLoadFailure,
})),
);
// unsubscribe // unsubscribe
return () => { return once(() => {
if (isUnsubscribed) return; parent.abort();
isUnsubscribed = true; unsubscribe();
cancelReloading(); });
preloading?.cancelLoading();
unsubscribeStores();
};
} }
protected log({ message, cssStyle = "", meta = {}}: IKubeWatchLog) { protected log(message: any, meta: any) {
if (isProduction && !isDebugging) { const log = message instanceof Error
return; ? console.error
} : console.debug;
const logInfo = [`%c[KUBE-WATCH-API]:`, `font-weight: bold; ${cssStyle}`, message].flat().map(String); log("[KUBE-WATCH-API]:", message, {
const logMeta = {
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
...meta, ...meta,
}; });
if (message instanceof Error) {
console.error(...logInfo, logMeta);
} else {
console.info(...logInfo, logMeta);
}
} }
} }

View File

@ -142,9 +142,9 @@ describe("kubeconfig manager tests", () => {
const configPath = await kubeConfManager.getPath(); const configPath = await kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true); expect(await fse.pathExists(configPath)).toBe(true);
await kubeConfManager.unlink(); await kubeConfManager.clear();
expect(await fse.pathExists(configPath)).toBe(false); expect(await fse.pathExists(configPath)).toBe(false);
await kubeConfManager.unlink(); // doesn't throw await kubeConfManager.clear(); // doesn't throw
expect(async () => { expect(async () => {
await kubeConfManager.getPath(); await kubeConfManager.getPath();
}).rejects.toThrow("already unlinked"); }).rejects.toThrow("already unlinked");

View File

@ -34,7 +34,7 @@ import { DetectorRegistry } from "./cluster-detectors/detector-registry";
import plimit from "p-limit"; import plimit from "p-limit";
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types";
import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types";
import { storedKubeConfigFolder, toJS } from "../common/utils"; import { disposer, storedKubeConfigFolder, toJS } from "../common/utils";
import type { Response } from "request"; import type { Response } from "request";
/** /**
@ -52,8 +52,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
*/ */
public contextHandler: ContextHandler; public contextHandler: ContextHandler;
protected kubeconfigManager: KubeconfigManager; protected proxyKubeconfigManager: KubeconfigManager;
protected eventDisposers: Function[] = []; protected eventsDisposer = disposer();
protected activated = false; protected activated = false;
private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map(); private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map();
@ -218,9 +218,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
*/ */
@computed get defaultNamespace(): string { @computed get defaultNamespace(): string {
const { defaultNamespace } = this.preferences; return this.preferences.defaultNamespace;
return defaultNamespace;
} }
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
@ -240,7 +238,7 @@ export class Cluster implements ClusterModel, ClusterState {
if (ipcMain) { if (ipcMain) {
// for the time being, until renderer gets its own cluster type // for the time being, until renderer gets its own cluster type
this.contextHandler = new ContextHandler(this); this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler); this.proxyKubeconfigManager = new KubeconfigManager(this, this.contextHandler);
logger.debug(`[CLUSTER]: Cluster init success`, { logger.debug(`[CLUSTER]: Cluster init success`, {
id: this.id, id: this.id,
@ -297,40 +295,31 @@ export class Cluster implements ClusterModel, ClusterState {
const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s
const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000); // every 15 minutes const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000); // every 15 minutes
if (ipcMain) { this.eventsDisposer.push(
this.eventDisposers.push( reaction(() => this.getState(), state => this.pushState(state)),
reaction(() => this.getState(), () => this.pushState()), reaction(
reaction(() => this.prometheusPreferences, (prefs) => this.contextHandler.setupPrometheus(prefs), { equals: comparer.structural }), () => this.prometheusPreferences,
() => { prefs => this.contextHandler.setupPrometheus(prefs),
clearInterval(refreshTimer); { equals: comparer.structural },
clearInterval(refreshMetadataTimer); ),
}, () => clearInterval(refreshTimer),
() => clearInterval(refreshMetadataTimer),
reaction(() => this.defaultNamespace, () => this.recreateProxyKubeconfig()), reaction(() => this.defaultNamespace, () => this.recreateProxyKubeconfig()),
); );
} }
}
/** /**
* @internal * @internal
*/ */
async recreateProxyKubeconfig() { protected async recreateProxyKubeconfig() {
logger.info("Recreate proxy kubeconfig"); logger.info("[CLUSTER]: Recreating proxy kubeconfig");
try { try {
this.kubeconfigManager.clear(); await this.proxyKubeconfigManager.clear();
} catch { await this.getProxyKubeconfig();
// do nothing } catch (error) {
logger.error(`[CLUSTER]: failed to recreate proxy kubeconfig`, error);
} }
this.getProxyKubeconfig();
}
/**
* internal
*/
protected unbindEvents() {
logger.info(`[CLUSTER]: unbind events`, this.getMeta());
this.eventDisposers.forEach(dispose => dispose());
this.eventDisposers.length = 0;
} }
/** /**
@ -345,7 +334,7 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: activate`, this.getMeta()); logger.info(`[CLUSTER]: activate`, this.getMeta());
if (!this.eventDisposers.length) { if (!this.eventsDisposer.length) {
this.bindEvents(); this.bindEvents();
} }
@ -395,7 +384,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
*/ */
@action disconnect() { @action disconnect() {
this.unbindEvents(); logger.info(`[CLUSTER]: disconnecting`, { id: this.id });
this.eventsDisposer();
this.contextHandler?.stopServer(); this.contextHandler?.stopServer();
this.disconnected = true; this.disconnected = true;
this.online = false; this.online = false;
@ -405,7 +395,7 @@ export class Cluster implements ClusterModel, ClusterState {
this.allowedNamespaces = []; this.allowedNamespaces = [];
this.resourceAccessStatuses.clear(); this.resourceAccessStatuses.clear();
this.pushState(); this.pushState();
logger.info(`[CLUSTER]: disconnect`, this.getMeta()); logger.info(`[CLUSTER]: disconnected`, { id: this.id });
} }
/** /**
@ -481,7 +471,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
*/ */
async getProxyKubeconfigPath(): Promise<string> { async getProxyKubeconfigPath(): Promise<string> {
return this.kubeconfigManager.getPath(); return this.proxyKubeconfigManager.getPath();
} }
protected async getConnectionStatus(): Promise<ClusterStatus> { protected async getConnectionStatus(): Promise<ClusterStatus> {

View File

@ -34,12 +34,19 @@ export interface PrometheusDetails {
provider: PrometheusProvider; provider: PrometheusProvider;
} }
interface PrometheusServicePreferences {
namespace: string;
service: string;
port: number;
prefix: string;
}
export class ContextHandler { export class ContextHandler {
public clusterUrl: UrlWithStringQuery; public clusterUrl: UrlWithStringQuery;
protected kubeAuthProxy?: KubeAuthProxy; protected kubeAuthProxy?: KubeAuthProxy;
protected apiTarget?: httpProxy.ServerOptions; protected apiTarget?: httpProxy.ServerOptions;
protected prometheusProvider?: string; protected prometheusProvider?: string;
protected prometheusPath: string | null; protected prometheus?: PrometheusServicePreferences;
constructor(protected cluster: Cluster) { constructor(protected cluster: Cluster) {
this.clusterUrl = url.parse(cluster.apiUrl); this.clusterUrl = url.parse(cluster.apiUrl);
@ -48,13 +55,7 @@ export class ContextHandler {
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
this.prometheusProvider = preferences.prometheusProvider?.type; this.prometheusProvider = preferences.prometheusProvider?.type;
this.prometheusPath = null; this.prometheus = preferences.prometheus || null;
if (preferences.prometheus) {
const { namespace, service, port } = preferences.prometheus;
this.prometheusPath = `${namespace}/services/${service}:${port}`;
}
} }
public async getPrometheusDetails(): Promise<PrometheusDetails> { public async getPrometheusDetails(): Promise<PrometheusDetails> {
@ -66,7 +67,7 @@ export class ContextHandler {
} }
protected ensurePrometheusPath({ service, namespace, port }: PrometheusService): string { protected ensurePrometheusPath({ service, namespace, port }: PrometheusService): string {
return this.prometheusPath ||= `${namespace}/services/${service}:${port}`; return `${namespace}/services/${service}:${port}`;
} }
protected ensurePrometheusProvider(service: PrometheusService): PrometheusProvider { protected ensurePrometheusProvider(service: PrometheusService): PrometheusProvider {
@ -90,6 +91,15 @@ export class ContextHandler {
} }
protected async getPrometheusService(): Promise<PrometheusService> { protected async getPrometheusService(): Promise<PrometheusService> {
if (this.prometheus !== null && this.prometheusProvider !== null) {
return {
id: this.prometheusProvider,
namespace: this.prometheus.namespace,
service: this.prometheus.service,
port: this.prometheus.port,
};
}
const providers = this.listPotentialProviders(); const providers = this.listPotentialProviders();
const proxyConfig = await this.cluster.getProxyKubeconfig(); const proxyConfig = await this.cluster.getProxyKubeconfig();
const apiClient = proxyConfig.makeApiClient(CoreV1Api); const apiClient = proxyConfig.makeApiClient(CoreV1Api);

View File

@ -32,30 +32,21 @@ import { makeObservable, observable, when } from "mobx";
const startingServeRegex = /^starting to serve on (?<address>.+)/i; const startingServeRegex = /^starting to serve on (?<address>.+)/i;
export class KubeAuthProxy { export class KubeAuthProxy {
public readonly apiPrefix: string; public readonly apiPrefix = `/${randomBytes(8).toString("hex")}`;
public get port(): number { public get port(): number {
return this._port; return this._port;
} }
protected _port?: number; protected _port: number;
protected cluster: Cluster; protected proxyProcess?: ChildProcess;
protected env: NodeJS.ProcessEnv = null; protected readonly acceptHosts: string;
protected proxyProcess: ChildProcess; @observable protected ready = false;
protected kubectl: Kubectl;
@observable protected ready: boolean;
constructor(cluster: Cluster, env: NodeJS.ProcessEnv) { constructor(protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) {
makeObservable(this); makeObservable(this);
this.ready = false;
this.env = env;
this.cluster = cluster;
this.kubectl = Kubectl.bundled();
this.apiPrefix = `/${randomBytes(8).toString("hex")}`;
}
get acceptHosts() { this.acceptHosts = url.parse(this.cluster.apiUrl).hostname;
return url.parse(this.cluster.apiUrl).hostname;
} }
get whenReady() { get whenReady() {
@ -67,7 +58,7 @@ export class KubeAuthProxy {
return this.whenReady; return this.whenReady;
} }
const proxyBin = await this.kubectl.getPath(); const proxyBin = await Kubectl.bundled().getPath();
const args = [ const args = [
"proxy", "proxy",
"-p", "0", "-p", "0",

View File

@ -30,57 +30,60 @@ import { LensProxy } from "./lens-proxy";
import { AppPaths } from "../common/app-paths"; import { AppPaths } from "../common/app-paths";
export class KubeconfigManager { export class KubeconfigManager {
protected configDir = AppPaths.get("temp"); /**
protected tempFile: string = null; * The path to the temp config file
*
* - if `string` then path
* - if `null` then not yet created
* - if `undefined` then unlinked by calling `clear()`
*/
protected tempFilePath: string | null | undefined = null;
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { } constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { }
/**
*
* @returns The path to the temporary kubeconfig
*/
async getPath(): Promise<string> { async getPath(): Promise<string> {
if (this.tempFile === undefined) { if (this.tempFilePath === undefined) {
throw new Error("kubeconfig is already unlinked"); throw new Error("kubeconfig is already unlinked");
} }
if (!this.tempFile) { if (this.tempFilePath === null || !(await fs.pathExists(this.tempFilePath))) {
await this.init(); await this.ensureFile();
} }
// create proxy kubeconfig if it is removed without unlink called return this.tempFilePath;
if (!(await fs.pathExists(this.tempFile))) { }
/**
* Deletes the temporary kubeconfig file
*/
async clear(): Promise<void> {
if (!this.tempFilePath) {
return;
}
logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFilePath}`);
try { try {
this.tempFile = await this.createProxyKubeconfig(); await fs.unlink(this.tempFilePath);
} catch (err) { } catch (error) {
logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, { err }); if (error.code !== "ENOENT") {
throw error;
}
} finally {
this.tempFilePath = undefined;
} }
} }
return this.tempFile; protected async ensureFile() {
}
async clear() {
if (!this.tempFile) {
return;
}
logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`);
await fs.unlink(this.tempFile);
}
async unlink() {
if (!this.tempFile) {
return;
}
logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`);
await fs.unlink(this.tempFile);
this.tempFile = undefined;
}
protected async init() {
try { try {
await this.contextHandler.ensureServer(); await this.contextHandler.ensureServer();
this.tempFile = await this.createProxyKubeconfig(); this.tempFilePath = await this.createProxyKubeconfig();
} catch (err) { } catch (error) {
logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, err); throw Object.assign(new Error("Failed to creat temp config for auth-proxy"), { cause: error });
} }
} }
@ -93,9 +96,9 @@ export class KubeconfigManager {
* This way any user of the config does not need to know anything about the auth etc. details. * This way any user of the config does not need to know anything about the auth etc. details.
*/ */
protected async createProxyKubeconfig(): Promise<string> { protected async createProxyKubeconfig(): Promise<string> {
const { configDir, cluster } = this; const { cluster } = this;
const { contextName, id } = cluster; const { contextName, id } = cluster;
const tempFile = path.join(configDir, `kubeconfig-${id}`); const tempFile = path.join(AppPaths.get("temp"), `kubeconfig-${id}`);
const kubeConfig = await cluster.getKubeconfig(); const kubeConfig = await cluster.getKubeconfig();
const proxyConfig: Partial<KubeConfig> = { const proxyConfig: Partial<KubeConfig> = {
currentContext: contextName, currentContext: contextName,

View File

@ -42,11 +42,11 @@ import whatInput from "what-input";
import { clusterSetFrameIdHandler } from "../common/cluster-ipc"; import { clusterSetFrameIdHandler } from "../common/cluster-ipc";
import { ClusterPageMenuRegistration, ClusterPageMenuRegistry } from "../extensions/registries"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry } from "../extensions/registries";
import { StatefulSetScaleDialog } from "./components/+workloads-statefulsets/statefulset-scale-dialog"; import { StatefulSetScaleDialog } from "./components/+workloads-statefulsets/statefulset-scale-dialog";
import { kubeWatchApi } from "../common/k8s-api/kube-watch-api"; import { KubeWatchApi, kubeWatchApi } from "../common/k8s-api/kube-watch-api";
import { ReplicaSetScaleDialog } from "./components/+workloads-replicasets/replicaset-scale-dialog"; import { ReplicaSetScaleDialog } from "./components/+workloads-replicasets/replicaset-scale-dialog";
import { CommandContainer } from "./components/command-palette/command-container"; import { CommandContainer } from "./components/command-palette/command-container";
import { KubeObjectStore } from "../common/k8s-api/kube-object.store"; import { KubeObjectStore } from "../common/k8s-api/kube-object.store";
import { clusterContext } from "./components/context"; import { FrameContext } from "./components/context";
import * as routes from "../common/routes"; import * as routes from "../common/routes";
import { TabLayout, TabLayoutRoute } from "./components/layout/tab-layout"; import { TabLayout, TabLayoutRoute } from "./components/layout/tab-layout";
import { ErrorBoundary } from "./components/error-boundary"; import { ErrorBoundary } from "./components/error-boundary";
@ -73,6 +73,8 @@ import { watchHistoryState } from "./remote-helpers/history-updater";
import { unmountComponentAtNode } from "react-dom"; import { unmountComponentAtNode } from "react-dom";
import { PortForwardDialog } from "./port-forward"; import { PortForwardDialog } from "./port-forward";
import { DeleteClusterDialog } from "./components/delete-cluster-dialog"; import { DeleteClusterDialog } from "./components/delete-cluster-dialog";
import { WorkloadsOverview } from "./components/+workloads-overview/overview";
import { KubeObjectListLayout } from "./components/kube-object-list-layout";
@observer @observer
export class ClusterFrame extends React.Component { export class ClusterFrame extends React.Component {
@ -91,10 +93,12 @@ export class ClusterFrame extends React.Component {
ClusterFrame.clusterId = getHostedClusterId(); ClusterFrame.clusterId = getHostedClusterId();
const cluster = ClusterStore.getInstance().getById(ClusterFrame.clusterId);
logger.info(`${ClusterFrame.logPrefix} Init dashboard, clusterId=${ClusterFrame.clusterId}, frameId=${frameId}`); logger.info(`${ClusterFrame.logPrefix} Init dashboard, clusterId=${ClusterFrame.clusterId}, frameId=${frameId}`);
await Terminal.preloadFonts(); await Terminal.preloadFonts();
await requestMain(clusterSetFrameIdHandler, ClusterFrame.clusterId); await requestMain(clusterSetFrameIdHandler, ClusterFrame.clusterId);
await ClusterStore.getInstance().getById(ClusterFrame.clusterId).whenReady; // cluster.activate() is done at this point await cluster.whenReady; // cluster.activate() is done at this point
catalogEntityRegistry.activeEntity = ClusterFrame.clusterId; catalogEntityRegistry.activeEntity = ClusterFrame.clusterId;
@ -120,16 +124,21 @@ export class ClusterFrame extends React.Component {
whatInput.ask(); // Start to monitor user input device whatInput.ask(); // Start to monitor user input device
const clusterContext = new FrameContext(cluster);
// Setup hosted cluster context // Setup hosted cluster context
KubeObjectStore.defaultContext.set(clusterContext); KubeObjectStore.defaultContext.set(clusterContext);
kubeWatchApi.context = clusterContext; WorkloadsOverview.clusterContext
= KubeObjectListLayout.clusterContext
= KubeWatchApi.context
= clusterContext;
} }
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([namespaceStore], { kubeWatchApi.subscribeStores([
preload: true, namespaceStore,
}), ]),
watchHistoryState(), watchHistoryState(),
]); ]);

View File

@ -55,9 +55,11 @@ export class ClusterOverview extends React.Component {
this.metricPoller.start(true); this.metricPoller.start(true);
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([podsStore, eventStore, nodesStore], { kubeWatchApi.subscribeStores([
preload: true, podsStore,
}), eventStore,
nodesStore,
]),
reaction( reaction(
() => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
() => this.metricPoller.restart(true), () => this.metricPoller.restart(true),

View File

@ -22,13 +22,14 @@
import "./kube-event-details.scss"; import "./kube-event-details.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { KubeObject } from "../../../common/k8s-api/kube-object"; import { KubeObject } from "../../../common/k8s-api/kube-object";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { LocaleDate } from "../locale-date"; import { LocaleDate } from "../locale-date";
import { eventStore } from "./event.store"; import { eventStore } from "./event.store";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
export interface KubeEventDetailsProps { export interface KubeEventDetailsProps {
object: KubeObject; object: KubeObject;
@ -36,8 +37,12 @@ export interface KubeEventDetailsProps {
@observer @observer
export class KubeEventDetails extends React.Component<KubeEventDetailsProps> { export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
async componentDidMount() { componentDidMount() {
eventStore.reloadAll(); disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([
eventStore,
]),
]);
} }
render() { render() {

View File

@ -39,6 +39,7 @@ import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { getDetailsUrl } from "../kube-detail-params"; import { getDetailsUrl } from "../kube-detail-params";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<Namespace> { interface Props extends KubeObjectDetailsProps<Namespace> {
} }
@ -52,14 +53,16 @@ export class NamespaceDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount
clean = reaction(() => this.props.object, () => {
this.metrics = null;
});
componentDidMount() { componentDidMount() {
resourceQuotaStore.reloadAll(); disposeOnUnmount(this, [
limitRangeStore.reloadAll(); reaction(() => this.props.object, () => {
this.metrics = null;
}),
kubeWatchApi.subscribeStores([
resourceQuotaStore,
limitRangeStore,
]),
]);
} }
@computed get quotas() { @computed get quotas() {

View File

@ -23,12 +23,11 @@ import "./namespace-select.scss";
import React from "react"; import React from "react";
import { computed, makeObservable } from "mobx"; import { computed, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import { Select, SelectOption, SelectProps } from "../select"; import { Select, SelectOption, SelectProps } from "../select";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { namespaceStore } from "./namespace.store"; import { namespaceStore } from "./namespace.store";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends SelectProps { interface Props extends SelectProps {
showIcons?: boolean; showIcons?: boolean;
@ -50,14 +49,7 @@ export class NamespaceSelect extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
componentDidMount() { // No subscribe here because the subscribe is in <App /> (the cluster frame root component)
disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([namespaceStore], {
preload: true,
loadOnce: true, // skip reloading namespaces on every render / page visit
}),
]);
}
@computed.struct get options(): SelectOption[] { @computed.struct get options(): SelectOption[] {
const { customizeOptions, showAllNamespacesOption, sort } = this.props; const { customizeOptions, showAllNamespacesOption, sort } = this.props;

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, reaction } from "mobx"; import { action, comparer, computed, IReactionDisposer, makeObservable, reaction } from "mobx";
import { autoBind, createStorage, noop, ToggleSet } from "../../utils"; import { autoBind, createStorage, noop, ToggleSet } from "../../utils";
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../../common/k8s-api/kube-object.store"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../../common/k8s-api/kube-object.store";
import { Namespace, namespacesApi } from "../../../common/k8s-api/endpoints/namespaces.api"; import { Namespace, namespacesApi } from "../../../common/k8s-api/endpoints/namespaces.api";
@ -45,10 +45,10 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
this.autoLoadAllowedNamespaces(); this.autoLoadAllowedNamespaces();
} }
public onContextChange(callback: (namespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer { public onContextChange(callback: (namespaces: string[]) => void, opts: { fireImmediately?: boolean } = {}): IReactionDisposer {
return reaction(() => Array.from(this.contextNamespaces), callback, { return reaction(() => Array.from(this.contextNamespaces), callback, {
fireImmediately: opts.fireImmediately,
equals: comparer.shallow, equals: comparer.shallow,
...opts,
}); });
} }
@ -133,7 +133,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
return super.subscribe(); return super.subscribe();
} }
protected async loadItems(params: KubeObjectStoreLoadingParams<Namespace>): Promise<Namespace[]> { protected async loadItems(params: KubeObjectStoreLoadingParams): Promise<Namespace[]> {
const { allowedNamespaces } = this; const { allowedNamespaces } = this;
let namespaces = await super.loadItems(params).catch(() => []); let namespaces = await super.loadItems(params).catch(() => []);
@ -205,7 +205,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
* explicitly deselected. * explicitly deselected.
* @param namespace The name of a namespace * @param namespace The name of a namespace
*/ */
toggleSingle(namespace: string){ toggleSingle(namespace: string) {
const nextState = new ToggleSet(this.contextNamespaces); const nextState = new ToggleSet(this.contextNamespaces);
nextState.toggle(namespace); nextState.toggle(namespace);

View File

@ -49,10 +49,13 @@ export class IngressDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount componentDidMount() {
clean = reaction(() => this.props.object, () => { disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.metrics = null; this.metrics = null;
}); }),
]);
}
@boundMethod @boundMethod
async loadMetrics() { async loadMetrics() {

View File

@ -44,8 +44,9 @@ export class ServiceDetails extends React.Component<Props> {
const { object: service } = this.props; const { object: service } = this.props;
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([endpointStore], { kubeWatchApi.subscribeStores([
preload: true, endpointStore,
], {
namespaces: [service.getNs()], namespaces: [service.getNs()],
}), }),
portForwardStore.watch(), portForwardStore.watch(),

View File

@ -41,6 +41,7 @@ import { NodeDetailsResources } from "./node-details-resources";
import { DrawerTitle } from "../drawer/drawer-title"; import { DrawerTitle } from "../drawer/drawer-title";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<Node> { interface Props extends KubeObjectDetailsProps<Node> {
} }
@ -54,13 +55,15 @@ export class NodeDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount componentDidMount() {
clean = reaction(() => this.props.object.getName(), () => { disposeOnUnmount(this, [
reaction(() => this.props.object.getName(), () => {
this.metrics = null; this.metrics = null;
}); }),
kubeWatchApi.subscribeStores([
async componentDidMount() { podsStore,
podsStore.reloadAll(); ]),
]);
} }
@boundMethod @boundMethod

View File

@ -25,7 +25,7 @@ import React from "react";
import startCase from "lodash/startCase"; import startCase from "lodash/startCase";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import type { KubeObjectDetailsProps } from "../kube-object-details"; import type { KubeObjectDetailsProps } from "../kube-object-details";
import { StorageClass } from "../../../common/k8s-api/endpoints"; import { StorageClass } from "../../../common/k8s-api/endpoints";
import { KubeObjectMeta } from "../kube-object-meta"; import { KubeObjectMeta } from "../kube-object-meta";
@ -33,14 +33,19 @@ import { storageClassStore } from "./storage-class.store";
import { VolumeDetailsList } from "../+storage-volumes/volume-details-list"; import { VolumeDetailsList } from "../+storage-volumes/volume-details-list";
import { volumesStore } from "../+storage-volumes/volumes.store"; import { volumesStore } from "../+storage-volumes/volumes.store";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<StorageClass> { interface Props extends KubeObjectDetailsProps<StorageClass> {
} }
@observer @observer
export class StorageClassDetails extends React.Component<Props> { export class StorageClassDetails extends React.Component<Props> {
async componentDidMount() { componentDidMount() {
volumesStore.reloadAll(); disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([
volumesStore,
]),
]);
} }
render() { render() {

View File

@ -51,10 +51,13 @@ export class PersistentVolumeClaimDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount componentDidMount() {
clean = reaction(() => this.props.object, () => { disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.metrics = null; this.metrics = null;
}); }),
]);
}
@boundMethod @boundMethod
async loadMetrics() { async loadMetrics() {

View File

@ -23,7 +23,7 @@ import "./cronjob-details.scss";
import React from "react"; import React from "react";
import kebabCase from "lodash/kebabCase"; import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { jobStore } from "../+workloads-jobs/job.store"; import { jobStore } from "../+workloads-jobs/job.store";
@ -34,14 +34,19 @@ import { getDetailsUrl } from "../kube-detail-params";
import { CronJob, Job } from "../../../common/k8s-api/endpoints"; import { CronJob, Job } from "../../../common/k8s-api/endpoints";
import { KubeObjectMeta } from "../kube-object-meta"; import { KubeObjectMeta } from "../kube-object-meta";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<CronJob> { interface Props extends KubeObjectDetailsProps<CronJob> {
} }
@observer @observer
export class CronJobDetails extends React.Component<Props> { export class CronJobDetails extends React.Component<Props> {
async componentDidMount() { componentDidMount() {
jobStore.reloadAll(); disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([
jobStore,
]),
]);
} }
render() { render() {

View File

@ -41,6 +41,7 @@ import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<DaemonSet> { interface Props extends KubeObjectDetailsProps<DaemonSet> {
} }
@ -54,13 +55,15 @@ export class DaemonSetDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount
clean = reaction(() => this.props.object, () => {
this.metrics = null;
});
componentDidMount() { componentDidMount() {
podsStore.reloadAll(); disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.metrics = null;
}),
kubeWatchApi.subscribeStores([
podsStore,
]),
]);
} }
@boundMethod @boundMethod

View File

@ -43,6 +43,7 @@ import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<Deployment> { interface Props extends KubeObjectDetailsProps<Deployment> {
} }
@ -56,14 +57,16 @@ export class DeploymentDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount
clean = reaction(() => this.props.object, () => {
this.metrics = null;
});
componentDidMount() { componentDidMount() {
podsStore.reloadAll(); disposeOnUnmount(this, [
replicaSetStore.reloadAll(); reaction(() => this.props.object, () => {
this.metrics = null;
}),
kubeWatchApi.subscribeStores([
podsStore,
replicaSetStore,
]),
]);
} }
@boundMethod @boundMethod

View File

@ -23,7 +23,7 @@ import "./job-details.scss";
import React from "react"; import React from "react";
import kebabCase from "lodash/kebabCase"; import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerItem } from "../drawer"; import { DrawerItem } from "../drawer";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses";
@ -36,7 +36,7 @@ import type { KubeObjectDetailsProps } from "../kube-object-details";
import { getMetricsForJobs, IPodMetrics, Job } from "../../../common/k8s-api/endpoints"; import { getMetricsForJobs, IPodMetrics, Job } from "../../../common/k8s-api/endpoints";
import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { PodDetailsList } from "../+workloads-pods/pod-details-list";
import { KubeObjectMeta } from "../kube-object-meta"; import { KubeObjectMeta } from "../kube-object-meta";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable, reaction } from "mobx";
import { podMetricTabs, PodCharts } from "../+workloads-pods/pod-charts"; import { podMetricTabs, PodCharts } from "../+workloads-pods/pod-charts";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
@ -45,6 +45,7 @@ import { boundMethod } from "autobind-decorator";
import { getDetailsUrl } from "../kube-detail-params"; import { getDetailsUrl } from "../kube-detail-params";
import { apiManager } from "../../../common/k8s-api/api-manager"; import { apiManager } from "../../../common/k8s-api/api-manager";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<Job> { interface Props extends KubeObjectDetailsProps<Job> {
} }
@ -58,8 +59,15 @@ export class JobDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
async componentDidMount() { componentDidMount() {
podsStore.reloadAll(); disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.metrics = null;
}),
kubeWatchApi.subscribeStores([
podsStore,
]),
]);
} }
@boundMethod @boundMethod

View File

@ -36,16 +36,18 @@ import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
import { WorkloadsOverviewDetailRegistry } from "../../../extensions/registries"; import { WorkloadsOverviewDetailRegistry } from "../../../extensions/registries";
import type { WorkloadsOverviewRouteParams } from "../../../common/routes"; import type { WorkloadsOverviewRouteParams } from "../../../common/routes";
import { makeObservable, observable, reaction } from "mobx"; import { makeObservable, observable, reaction } from "mobx";
import { clusterContext } from "../context";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { TooltipPosition } from "../tooltip"; import { TooltipPosition } from "../tooltip";
import type { ClusterContext } from "../../../common/k8s-api/cluster-context";
interface Props extends RouteComponentProps<WorkloadsOverviewRouteParams> { interface Props extends RouteComponentProps<WorkloadsOverviewRouteParams> {
} }
@observer @observer
export class WorkloadsOverview extends React.Component<Props> { export class WorkloadsOverview extends React.Component<Props> {
static clusterContext: ClusterContext;
@observable loadErrors: string[] = []; @observable loadErrors: string[] = [];
constructor(props: Props) { constructor(props: Props) {
@ -56,12 +58,18 @@ export class WorkloadsOverview extends React.Component<Props> {
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([ kubeWatchApi.subscribeStores([
podsStore, deploymentStore, daemonSetStore, statefulSetStore, replicaSetStore, cronJobStore,
jobStore, cronJobStore, eventStore, daemonSetStore,
deploymentStore,
eventStore,
jobStore,
podsStore,
replicaSetStore,
statefulSetStore,
], { ], {
onLoadFailure: error => this.loadErrors.push(String(error)), onLoadFailure: error => this.loadErrors.push(String(error)),
}), }),
reaction(() => clusterContext.contextNamespaces.slice(), () => { reaction(() => WorkloadsOverview.clusterContext.contextNamespaces.slice(), () => {
// clear load errors // clear load errors
this.loadErrors.length = 0; this.loadErrors.length = 0;
}), }),

View File

@ -40,6 +40,7 @@ import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<ReplicaSet> { interface Props extends KubeObjectDetailsProps<ReplicaSet> {
} }
@ -53,13 +54,15 @@ export class ReplicaSetDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount componentDidMount() {
clean = reaction(() => this.props.object, () => { disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.metrics = null; this.metrics = null;
}); }),
kubeWatchApi.subscribeStores([
async componentDidMount() { podsStore,
podsStore.reloadAll(); ]),
]);
} }
@boundMethod @boundMethod

View File

@ -41,6 +41,7 @@ import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
import logger from "../../../common/logger"; import logger from "../../../common/logger";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
interface Props extends KubeObjectDetailsProps<StatefulSet> { interface Props extends KubeObjectDetailsProps<StatefulSet> {
} }
@ -54,13 +55,15 @@ export class StatefulSetDetails extends React.Component<Props> {
makeObservable(this); makeObservable(this);
} }
@disposeOnUnmount
clean = reaction(() => this.props.object, () => {
this.metrics = null;
});
componentDidMount() { componentDidMount() {
podsStore.reloadAll(); disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.metrics = null;
}),
kubeWatchApi.subscribeStores([
podsStore,
]),
]);
} }
@boundMethod @boundMethod

View File

@ -25,7 +25,6 @@ import { computed, observable, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import React from "react"; import React from "react";
import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { clusterActivateHandler } from "../../../common/cluster-ipc";
import { ClusterStore } from "../../../common/cluster-store";
import { ipcRendererOn, requestMain } from "../../../common/ipc"; import { ipcRendererOn, requestMain } from "../../../common/ipc";
import type { Cluster } from "../../../main/cluster"; import type { Cluster } from "../../../main/cluster";
import { cssNames, IClassName } from "../../utils"; import { cssNames, IClassName } from "../../utils";
@ -34,11 +33,12 @@ import { Icon } from "../icon";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { entitySettingsURL } from "../../../common/routes"; import { entitySettingsURL } from "../../../common/routes";
import type { ClusterId, KubeAuthUpdate } from "../../../common/cluster-types"; import type { KubeAuthUpdate } from "../../../common/cluster-types";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
clusterId: ClusterId; cluster: Cluster;
} }
@observer @observer
@ -52,7 +52,11 @@ export class ClusterStatus extends React.Component<Props> {
} }
get cluster(): Cluster { get cluster(): Cluster {
return ClusterStore.getInstance().getById(this.props.clusterId); return this.props.cluster;
}
@computed get entity() {
return catalogEntityRegistry.getById(this.cluster.id);
} }
@computed get hasErrors(): boolean { @computed get hasErrors(): boolean {
@ -72,7 +76,7 @@ export class ClusterStatus extends React.Component<Props> {
this.isReconnecting = true; this.isReconnecting = true;
try { try {
await requestMain(clusterActivateHandler, this.props.clusterId, true); await requestMain(clusterActivateHandler, this.cluster.id, true);
} catch (error) { } catch (error) {
this.authOutput.push({ this.authOutput.push({
message: error.toString(), message: error.toString(),
@ -86,7 +90,7 @@ export class ClusterStatus extends React.Component<Props> {
manageProxySettings = () => { manageProxySettings = () => {
navigate(entitySettingsURL({ navigate(entitySettingsURL({
params: { params: {
entityId: this.props.clusterId, entityId: this.cluster.id,
}, },
fragment: "proxy", fragment: "proxy",
})); }));
@ -149,7 +153,7 @@ export class ClusterStatus extends React.Component<Props> {
return ( return (
<div className={cssNames(styles.status, "flex column box center align-center justify-center", this.props.className)}> <div className={cssNames(styles.status, "flex column box center align-center justify-center", this.props.className)}>
<div className="flex items-center column gaps"> <div className="flex items-center column gaps">
<h2>{this.cluster.preferences.clusterName}</h2> <h2>{this.entity.getName()}</h2>
{this.renderStatusIcon()} {this.renderStatusIcon()}
{this.renderAuthenticationOutput()} {this.renderAuthenticationOutput()}
{this.renderReconnectionHelp()} {this.renderReconnectionHelp()}

View File

@ -89,10 +89,10 @@ export class ClusterView extends React.Component<Props> {
} }
renderStatus(): React.ReactNode { renderStatus(): React.ReactNode {
const { clusterId, cluster, isReady } = this; const { cluster, isReady } = this;
if (cluster && !isReady) { if (cluster && !isReady) {
return <ClusterStatus key={clusterId} clusterId={clusterId} className="box center"/>; return <ClusterStatus cluster={cluster} className="box center"/>;
} }
return null; return null;

View File

@ -19,24 +19,19 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { ClusterStore } from "../../common/cluster-store";
import type { Cluster } from "../../main/cluster"; import type { Cluster } from "../../main/cluster";
import { getHostedClusterId } from "../utils";
import { namespaceStore } from "./+namespaces/namespace.store"; import { namespaceStore } from "./+namespaces/namespace.store";
import type { ClusterContext } from "../../common/k8s-api/cluster-context"; import type { ClusterContext } from "../../common/k8s-api/cluster-context";
import { computed, makeObservable } from "mobx";
export const clusterContext: ClusterContext = { export class FrameContext implements ClusterContext {
get cluster(): Cluster | null { constructor(public cluster: Cluster) {
return ClusterStore.getInstance().getById(getHostedClusterId()); makeObservable(this);
},
get allNamespaces(): string[] {
if (!this.cluster) {
return [];
} }
@computed get allNamespaces(): string[] {
// user given list of namespaces // user given list of namespaces
if (this.cluster?.accessibleNamespaces.length) { if (this.cluster.accessibleNamespaces.length) {
return this.cluster.accessibleNamespaces; return this.cluster.accessibleNamespaces;
} }
@ -47,9 +42,17 @@ export const clusterContext: ClusterContext = {
// fallback to cluster resolved namespaces because we could not load list // fallback to cluster resolved namespaces because we could not load list
return this.cluster.allowedNamespaces || []; return this.cluster.allowedNamespaces || [];
} }
}, }
get contextNamespaces(): string[] { @computed get contextNamespaces(): string[] {
return namespaceStore.contextNamespaces ?? []; return namespaceStore.contextNamespaces;
}, }
};
@computed get hasSelectedAll(): boolean {
const namespaces = new Set(this.contextNamespaces);
return this.allNamespaces?.length > 1
&& this.cluster.accessibleNamespaces.length === 0
&& this.allNamespaces.every(ns => namespaces.has(ns));
}
}

View File

@ -52,15 +52,12 @@ type Props = {};
@observer @observer
export class DeleteClusterDialog extends React.Component { export class DeleteClusterDialog extends React.Component {
showContextSwitch = false; @observable showContextSwitch = false;
newCurrentContext = ""; @observable newCurrentContext = "";
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
makeObservable(this, { makeObservable(this);
showContextSwitch: observable,
newCurrentContext: observable,
});
} }
static open({ config, cluster }: Partial<DialogState>) { static open({ config, cluster }: Partial<DialogState>) {

View File

@ -20,7 +20,7 @@
*/ */
import * as uuid from "uuid"; import * as uuid from "uuid";
import { action, comparer, computed, IReactionOptions, makeObservable, observable, reaction, runInAction } from "mobx"; import { action, comparer, computed, makeObservable, observable, reaction, runInAction } from "mobx";
import { autoBind, createStorage } from "../../utils"; import { autoBind, createStorage } from "../../utils";
import throttle from "lodash/throttle"; import throttle from "lodash/throttle";
@ -94,7 +94,11 @@ export interface DockTabChangeEvent {
prevTab?: DockTab; prevTab?: DockTab;
} }
export interface DockTabChangeEventOptions extends IReactionOptions { export interface DockTabChangeEventOptions {
/**
* apply a callback right after initialization
*/
fireImmediately?: boolean;
/** /**
* filter: by dockStore.selectedTab.kind == tabKind * filter: by dockStore.selectedTab.kind == tabKind
*/ */
@ -195,11 +199,13 @@ export class DockStore implements DockStorageState {
if (this.height > this.maxHeight) this.height = this.maxHeight; if (this.height > this.maxHeight) this.height = this.maxHeight;
} }
onResize(callback: () => void, options?: IReactionOptions) { onResize(callback: () => void, opts: { fireImmediately?: boolean } = {}) {
return reaction(() => [this.height, this.fullSize], callback, options); return reaction(() => [this.height, this.fullSize], callback, {
fireImmediately: opts.fireImmediately,
});
} }
onTabClose(callback: (evt: DockTabCloseEvent) => void, options: IReactionOptions = {}) { onTabClose(callback: (evt: DockTabCloseEvent) => void, opts: { fireImmediately?: boolean } = {}) {
return reaction(() => dockStore.tabs.map(tab => tab.id), (tabs: TabId[], prevTabs?: TabId[]) => { return reaction(() => dockStore.tabs.map(tab => tab.id), (tabs: TabId[], prevTabs?: TabId[]) => {
if (!Array.isArray(prevTabs)) { if (!Array.isArray(prevTabs)) {
return; // tabs not yet modified return; // tabs not yet modified
@ -214,7 +220,7 @@ export class DockStore implements DockStorageState {
} }
}, { }, {
equals: comparer.structural, equals: comparer.structural,
...options, fireImmediately: opts.fireImmediately,
}); });
} }

View File

@ -29,6 +29,14 @@
width: var(--hotbar-width); width: var(--hotbar-width);
overflow: hidden; overflow: hidden;
&.draggingOver::after {
content: " ";
position: fixed;
left: var(--hotbar-width);
width: 100%;
height: 100%;
}
.HotbarItems { .HotbarItems {
--cellWidth: 40px; --cellWidth: 40px;
--cellHeight: 40px; --cellHeight: 40px;

View File

@ -33,6 +33,7 @@ import { HotbarSelector } from "./hotbar-selector";
import { HotbarCell } from "./hotbar-cell"; import { HotbarCell } from "./hotbar-cell";
import { HotbarIcon } from "./hotbar-icon"; import { HotbarIcon } from "./hotbar-icon";
import { defaultHotbarCells, HotbarItem } from "../../../common/hotbar-types"; import { defaultHotbarCells, HotbarItem } from "../../../common/hotbar-types";
import { action, makeObservable, observable } from "mobx";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
@ -40,6 +41,13 @@ interface Props {
@observer @observer
export class HotbarMenu extends React.Component<Props> { export class HotbarMenu extends React.Component<Props> {
@observable draggingOver = false;
constructor(props: Props) {
super(props);
makeObservable(this);
}
get hotbar() { get hotbar() {
return HotbarStore.getInstance().getActive(); return HotbarStore.getInstance().getActive();
} }
@ -54,9 +62,17 @@ export class HotbarMenu extends React.Component<Props> {
return catalogEntityRegistry.getById(item?.entity.uid) ?? null; return catalogEntityRegistry.getById(item?.entity.uid) ?? null;
} }
@action
onDragStart() {
this.draggingOver = true;
}
@action
onDragEnd(result: DropResult) { onDragEnd(result: DropResult) {
const { source, destination } = result; const { source, destination } = result;
this.draggingOver = false;
if (!destination) { // Dropped outside of the list if (!destination) { // Dropped outside of the list
return; return;
} }
@ -165,9 +181,9 @@ export class HotbarMenu extends React.Component<Props> {
const hotbar = hotbarStore.getActive(); const hotbar = hotbarStore.getActive();
return ( return (
<div className={cssNames("HotbarMenu flex column", className)}> <div className={cssNames("HotbarMenu flex column", { draggingOver: this.draggingOver }, className)}>
<div className="HotbarItems flex column gaps"> <div className="HotbarItems flex column gaps">
<DragDropContext onDragEnd={this.onDragEnd.bind(this)}> <DragDropContext onDragStart={() => this.onDragStart()} onDragEnd={(result) => this.onDragEnd(result)}>
{this.renderGrid()} {this.renderGrid()}
</DragDropContext> </DragDropContext>
</div> </div>

View File

@ -30,12 +30,12 @@ import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-li
import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { KubeObjectMenu } from "../kube-object-menu"; import { KubeObjectMenu } from "../kube-object-menu";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
import { clusterContext } from "../context";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { ResourceKindMap, ResourceNames } from "../../utils/rbac"; import { ResourceKindMap, ResourceNames } from "../../utils/rbac";
import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { TooltipPosition } from "../tooltip"; import { TooltipPosition } from "../tooltip";
import type { ClusterContext } from "../../../common/k8s-api/cluster-context";
export interface KubeObjectListLayoutProps<K extends KubeObject> extends ItemListLayoutProps<K> { export interface KubeObjectListLayoutProps<K extends KubeObject> extends ItemListLayoutProps<K> {
store: KubeObjectStore<K>; store: KubeObjectStore<K>;
@ -51,6 +51,7 @@ const defaultProps: Partial<KubeObjectListLayoutProps<KubeObject>> = {
@observer @observer
export class KubeObjectListLayout<K extends KubeObject> extends React.Component<KubeObjectListLayoutProps<K>> { export class KubeObjectListLayout<K extends KubeObject> extends React.Component<KubeObjectListLayoutProps<K>> {
static defaultProps = defaultProps as object; static defaultProps = defaultProps as object;
static clusterContext: ClusterContext;
constructor(props: KubeObjectListLayoutProps<K>) { constructor(props: KubeObjectListLayoutProps<K>) {
super(props); super(props);
@ -67,7 +68,7 @@ export class KubeObjectListLayout<K extends KubeObject> extends React.Component<
const { store, dependentStores = [], subscribeStores } = this.props; const { store, dependentStores = [], subscribeStores } = this.props;
const stores = Array.from(new Set([store, ...dependentStores])); const stores = Array.from(new Set([store, ...dependentStores]));
const reactions: Disposer[] = [ const reactions: Disposer[] = [
reaction(() => clusterContext.contextNamespaces.slice(), () => { reaction(() => KubeObjectListLayout.clusterContext.contextNamespaces.slice(), () => {
// clear load errors // clear load errors
this.loadErrors.length = 0; this.loadErrors.length = 0;
}), }),
@ -76,8 +77,6 @@ export class KubeObjectListLayout<K extends KubeObject> extends React.Component<
if (subscribeStores) { if (subscribeStores) {
reactions.push( reactions.push(
kubeWatchApi.subscribeStores(stores, { kubeWatchApi.subscribeStores(stores, {
preload: true,
namespaces: clusterContext.contextNamespaces,
onLoadFailure: error => this.loadErrors.push(String(error)), onLoadFailure: error => this.loadErrors.push(String(error)),
}), }),
); );

View File

@ -54,7 +54,9 @@ export class Sidebar extends React.Component<Props> {
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([crdStore]), kubeWatchApi.subscribeStores([
crdStore,
]),
]); ]);
} }

View File

@ -50,6 +50,7 @@
white-space: pre-line; white-space: pre-line;
padding-left: $padding; padding-left: $padding;
padding-right: $padding * 2; padding-right: $padding * 2;
align-self: center;
a { a {
color: inherit; color: inherit;
@ -63,9 +64,5 @@
box-shadow: 0 0 20px var(--boxShadow); box-shadow: 0 0 20px var(--boxShadow);
} }
} }
.close {
margin-top: -2px;
}
} }
} }

View File

@ -9535,10 +9535,10 @@ mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdirp@~0.5.0:
dependencies: dependencies:
minimist "^1.2.5" minimist "^1.2.5"
mobx-observable-history@^2.0.1: mobx-observable-history@^2.0.3:
version "2.0.1" version "2.0.3"
resolved "https://registry.yarnpkg.com/mobx-observable-history/-/mobx-observable-history-2.0.1.tgz#bb39f19346e094c3608bbfba4b2b0aca985a562f" resolved "https://registry.yarnpkg.com/mobx-observable-history/-/mobx-observable-history-2.0.3.tgz#07dd551e9d2a5666ca1d759ad108173fab47125e"
integrity sha512-ijNuz2iBl5SYRdvVIqK3yeRJEGxWS7Oqq14ideNUDAvNZUBk/iCOmzWMgR6vfwp3bYieX/6tANS8N6RjVTKwGg== integrity sha512-cWMG3GcT1l2Y880mfffNh9m6WldQyOtlLUvcdVUjIj++sNOQbRxKBaBUe/TPDiJ80EN6g8FGiVuFlzzyRJPykQ==
dependencies: dependencies:
"@types/history" "^4.7.8" "@types/history" "^4.7.8"
history "^4.10.1" history "^4.10.1"
@ -9561,6 +9561,11 @@ mobx@^6.3.0:
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.3.0.tgz#a8fb693c3047bdfcb1eaff9aa48e36a7eb084f96" resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.3.0.tgz#a8fb693c3047bdfcb1eaff9aa48e36a7eb084f96"
integrity sha512-Aa1+VXsg4WxqJMTQfWoYuJi5UD10VZhiobSmcs5kcmI3BIT0aVtn7DysvCeDADCzl7dnbX+0BTHUj/v7gLlZpQ== integrity sha512-Aa1+VXsg4WxqJMTQfWoYuJi5UD10VZhiobSmcs5kcmI3BIT0aVtn7DysvCeDADCzl7dnbX+0BTHUj/v7gLlZpQ==
mobx@^6.3.7:
version "6.3.7"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.3.7.tgz#9ed85561e86da45141134c8fa20cf5f9c7246c3d"
integrity sha512-X7yU7eOEyxIBk4gjIi2UIilwdw48gXh0kcZ5ex3Rc+COJsJmJ4SNpf42uYea3aUqb1hedTv5xzJrq5Q55p0P5g==
mock-fs@^4.14.0: mock-fs@^4.14.0:
version "4.14.0" version "4.14.0"
resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.14.0.tgz#ce5124d2c601421255985e6e94da80a7357b1b18" resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.14.0.tgz#ce5124d2c601421255985e6e94da80a7357b1b18"