diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 0387c27206..60adcee6c7 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -1,14 +1,14 @@ // Kubernetes watch-api client // API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams -import type { Cluster } from "../../main/cluster"; import type { IKubeWatchEvent, IKubeWatchEventStreamEnd, IWatchRoutePayload } from "../../main/routes/watch-route"; import type { KubeObject } from "./kube-object"; import type { KubeObjectStore } from "../kube-object.store"; +import type { ClusterContext } from "../components/context"; import plimit from "p-limit"; import debounce from "lodash/debounce"; -import { autorun, comparer, computed, IReactionDisposer, observable, reaction } from "mobx"; +import { comparer, computed, IReactionDisposer, observable, reaction, when } from "mobx"; import { autobind, EventEmitter, noop } from "../utils"; import { ensureObjectSelfLink, KubeApi, parseKubeApi } from "./kube-api"; import { KubeJsonApiData, KubeJsonApiError } from "./kube-json-api"; @@ -48,21 +48,18 @@ export class KubeWatchApi { private reader: ReadableStreamReader; public onMessage = new EventEmitter<[IKubeWatchMessage]>(); - @observable.ref private cluster: Cluster; - @observable.ref private namespaces: string[] = []; + @observable context: ClusterContext = null; @observable subscribers = observable.map(); @observable isConnected = false; - @computed get isReady(): boolean { - return Boolean(this.cluster && this.namespaces); - } + contextReady = when(() => Boolean(this.context)); @computed get isActive(): boolean { return this.apis.length > 0; } @computed get apis(): string[] { - if (!this.isReady) { + if (!this.context) { return []; } @@ -72,22 +69,20 @@ export class KubeWatchApi { } // TODO: optimize - check when all namespaces are selected and then request all in one - if (api.isNamespaced && !this.cluster.isGlobalWatchEnabled) { - return this.namespaces.map(namespace => api.getWatchUrl(namespace)); + if (api.isNamespaced && !this.context.cluster.isGlobalWatchEnabled) { + return this.context.contextNamespaces.map(namespace => api.getWatchUrl(namespace)); } return api.getWatchUrl(); }).flat(); } - async init({ getCluster, getNamespaces }: { - getCluster: () => Cluster, - getNamespaces: () => string[], - }): Promise { - autorun(() => { - this.cluster = getCluster(); - this.namespaces = getNamespaces(); - }); + constructor() { + this.init(); + } + + private async init() { + await this.contextReady; this.bindAutoConnect(); } @@ -109,7 +104,7 @@ export class KubeWatchApi { } isAllowedApi(api: KubeApi): boolean { - return Boolean(this?.cluster.isAllowedResource(api.kind)); + return Boolean(this.context?.cluster.isAllowedResource(api.kind)); } subscribeApi(api: KubeApi | KubeApi[]): () => void { @@ -130,15 +125,15 @@ export class KubeWatchApi { }; } - preloadStores(stores: KubeObjectStore[], { loadOnce = false } = {}) { + preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) { const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const preloading: Promise[] = []; for (const store of stores) { preloading.push(limitRequests(async () => { - if (store.isLoaded && loadOnce) return; // skip + if (store.isLoaded && opts.loadOnce) return; // skip - return store.loadAll(this.namespaces); + return store.loadAll({ namespaces: opts.namespaces }); })); } @@ -154,7 +149,7 @@ export class KubeWatchApi { const unsubscribeList: (() => void)[] = []; let isUnsubscribed = false; - const load = () => this.preloadStores(stores, { loadOnce }); + const load = (namespaces?: string[]) => this.preloadStores(stores, { namespaces, loadOnce }); let preloading = preload && load(); let cancelReloading: IReactionDisposer = noop; @@ -175,10 +170,10 @@ export class KubeWatchApi { subscribe(); } - // reload when context namespaces changes - cancelReloading = reaction(() => this.namespaces, () => { + // partial reload only selected namespaces + cancelReloading = reaction(() => this.context.contextNamespaces, namespaces => { preloading?.cancelLoading(); - preloading = load(); + preloading = load(namespaces); }, { equals: comparer.shallow, }); @@ -291,7 +286,7 @@ export class KubeWatchApi { } // skip updates from non-watching resources context - if (!namespace || this.namespaces.includes(namespace)) { + if (!namespace || this.context?.contextNamespaces.includes(namespace)) { this.onMessage.emit(message); } } catch (error) { diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 4a9290eb4e..afc6dd87c2 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -30,7 +30,7 @@ export class CrdResources extends React.Component { const { store } = this; if (store && !store.isLoading && !store.isLoaded) { - store.loadAllFromContextNamespaces(); + store.reloadAll(); } }) ]); @@ -97,7 +97,7 @@ export class CrdResources extends React.Component { ...extraColumns.map((column) => { let value = jsonPath.value(crdInstance, parseJsonPath(column.jsonPath.slice(1))); - if (Array.isArray(value) || typeof value === "object") { + if (Array.isArray(value) || typeof value === "object") { value = JSON.stringify(value); } diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 9ac4587395..264b99f11b 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -14,7 +14,7 @@ export interface KubeEventDetailsProps { @observer export class KubeEventDetails extends React.Component { async componentDidMount() { - eventStore.loadAllFromContextNamespaces(); + eventStore.reloadAll(); } render() { diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index c8cf0f6bcb..2ad6d7d0da 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -32,8 +32,8 @@ export class NamespaceDetails extends React.Component { } componentDidMount() { - resourceQuotaStore.loadAllFromContextNamespaces(); - limitRangeStore.loadAllFromContextNamespaces(); + resourceQuotaStore.reloadAll(); + limitRangeStore.reloadAll(); } render() { diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 4ea270a3af..d62d3767e7 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -41,8 +41,7 @@ export class NamespaceStore extends KubeObjectStore { } private async init() { - await this.resolveCluster(); - if (!this.cluster) return; // skip for non-cluster context window + if (!this.context) return; // skip for non-cluster context window this.setContext(this.initialNamespaces); this.autoLoadAllowedNamespaces(); @@ -66,7 +65,7 @@ export class NamespaceStore extends KubeObjectStore { } private autoLoadAllowedNamespaces(): IReactionDisposer { - return reaction(() => this.allowedNamespaces, namespaces => this.loadAll(namespaces), { + return reaction(() => this.allowedNamespaces, namespaces => this.loadAll({ namespaces }), { fireImmediately: true, equals: comparer.shallow, }); @@ -94,8 +93,8 @@ export class NamespaceStore extends KubeObjectStore { @computed get allowedNamespaces(): string[] { return Array.from(new Set([ - ...(this.cluster?.allowedNamespaces ?? []), // loaded names from main, updating every 30s and thus might be stale - ...this.items.map(item => item.getName()), // loaded names from hosted cluster + ...(this.context?.allNamespaces ?? []), // allowed namespaces from cluster (main), updating every 30s + ...this.items.map(item => item.getName()), // loaded namespaces from k8s api ].flat())); } @@ -111,7 +110,7 @@ export class NamespaceStore extends KubeObjectStore { getSubscribeApis() { // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted - if (this.cluster?.accessibleNamespaces.length > 0) { + if (this.context?.cluster.accessibleNamespaces.length > 0) { return []; } diff --git a/src/renderer/components/+nodes/node-details.tsx b/src/renderer/components/+nodes/node-details.tsx index 455005db25..810837d59d 100644 --- a/src/renderer/components/+nodes/node-details.tsx +++ b/src/renderer/components/+nodes/node-details.tsx @@ -29,7 +29,7 @@ export class NodeDetails extends React.Component { }); async componentDidMount() { - podsStore.loadAllFromContextNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx index fde70e1290..85d27243c2 100644 --- a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx +++ b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogProps } from "../dialog"; import { Wizard, WizardStep } from "../wizard"; import { Select, SelectOption } from "../select"; import { SubTitle } from "../layout/sub-title"; -import { IRoleBindingSubject, RoleBinding, ServiceAccount, Role } from "../../api/endpoints"; +import { IRoleBindingSubject, Role, RoleBinding, ServiceAccount } from "../../api/endpoints"; import { Icon } from "../icon"; import { Input } from "../input"; import { NamespaceSelect } from "../+namespaces/namespace-select"; @@ -19,6 +19,7 @@ import { namespaceStore } from "../+namespaces/namespace.store"; import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store"; import { roleBindingsStore } from "./role-bindings.store"; import { showDetails } from "../kube-object"; +import { KubeObjectStore } from "../../kube-object.store"; interface BindingSelectOption extends SelectOption { value: string; // binding name @@ -73,14 +74,14 @@ export class AddRoleBindingDialog extends React.Component { }; async loadData() { - const stores = [ + const stores: KubeObjectStore[] = [ namespaceStore, rolesStore, serviceAccountsStore, ]; this.isLoading = true; - await Promise.all(stores.map(store => store.loadAllFromContextNamespaces())); + await Promise.all(stores.map(store => store.reloadAll())); this.isLoading = false; } @@ -136,8 +137,7 @@ export class AddRoleBindingDialog extends React.Component { roleBinding: this.roleBinding, addSubjects: subjects, }); - } - else { + } else { const name = useRoleForBindingName ? selectedRole.getName() : bindingName; roleBinding = await roleBindingsStore.create({ name, namespace }, { @@ -265,7 +265,7 @@ export class AddRoleBindingDialog extends React.Component { ); const disableNext = this.isLoading || !selectedRole || !selectedBindings.length; - const nextLabel = isEditing ? "Update" : "Create"; + const nextLabel = isEditing ? "Update" : "Create"; return ( { @observer export class CronJobDetails extends React.Component { async componentDidMount() { - jobStore.loadAllFromContextNamespaces(); + jobStore.reloadAll(); } render() { diff --git a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx index 68f20e4b54..329eaf3ed7 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx @@ -30,7 +30,7 @@ export class DaemonSetDetails extends React.Component { }); componentDidMount() { - podsStore.loadAllFromContextNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+workloads-deployments/deployment-details.tsx b/src/renderer/components/+workloads-deployments/deployment-details.tsx index 426d8f40c6..e31f63d7d7 100644 --- a/src/renderer/components/+workloads-deployments/deployment-details.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-details.tsx @@ -31,7 +31,7 @@ export class DeploymentDetails extends React.Component { }); componentDidMount() { - podsStore.loadAllFromContextNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+workloads-jobs/job-details.tsx b/src/renderer/components/+workloads-jobs/job-details.tsx index 21a4edd4e2..f0665bd291 100644 --- a/src/renderer/components/+workloads-jobs/job-details.tsx +++ b/src/renderer/components/+workloads-jobs/job-details.tsx @@ -25,7 +25,7 @@ interface Props extends KubeObjectDetailsProps { @observer export class JobDetails extends React.Component { async componentDidMount() { - podsStore.loadAllFromContextNamespaces(); + podsStore.reloadAll(); } render() { diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx index c0a4ddc549..0cf747a1d1 100644 --- a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx @@ -29,7 +29,7 @@ export class ReplicaSetDetails extends React.Component { }); async componentDidMount() { - podsStore.loadAllFromContextNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx index c386f32503..f1f86b6b5f 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx @@ -30,7 +30,7 @@ export class StatefulSetDetails extends React.Component { }); componentDidMount() { - podsStore.loadAllFromContextNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 8dd711e4ed..8b7f8a527c 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -43,12 +43,13 @@ import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../exte import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { eventStore } from "./+events/event.store"; -import { namespaceStore } from "./+namespaces/namespace.store"; import { nodesStore } from "./+nodes/nodes.store"; import { podsStore } from "./+workloads-pods/pods.store"; import { kubeWatchApi } from "../api/kube-watch-api"; import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; import { CommandContainer } from "./command-palette/command-container"; +import { KubeObjectStore } from "../kube-object.store"; +import { clusterContext } from "./context"; @observer export class App extends React.Component { @@ -76,10 +77,9 @@ export class App extends React.Component { }); whatInput.ask(); // Start to monitor user input device - await kubeWatchApi.init({ - getCluster: () => getHostedCluster(), - getNamespaces: () => namespaceStore.contextNamespaces, - }); + // Setup hosted cluster context + KubeObjectStore.defaultContext = clusterContext; + kubeWatchApi.context = clusterContext; } componentDidMount() { @@ -162,9 +162,9 @@ export class App extends React.Component { const tabRoutes = this.getTabLayoutRoutes(menu); if (tabRoutes.length > 0) { - const pageComponent = () => ; + const pageComponent = () => ; - route = tab.routePath)} />; + route = tab.routePath)}/>; this.extensionRoutes.set(menu, route); } else { const page = clusterPageRegistry.getByPageTarget(menu.target); @@ -228,7 +228,7 @@ export class App extends React.Component { - + ); diff --git a/src/renderer/components/context.ts b/src/renderer/components/context.ts new file mode 100755 index 0000000000..3c8c6d29e4 --- /dev/null +++ b/src/renderer/components/context.ts @@ -0,0 +1,23 @@ +import type { Cluster } from "../../main/cluster"; +import { getHostedCluster } from "../../common/cluster-store"; +import { namespaceStore } from "./+namespaces/namespace.store"; + +export interface ClusterContext { + cluster?: Cluster; + allNamespaces?: string[]; // available / allowed namespaces from cluster.ts + contextNamespaces?: string[]; // selected by user (see: namespace-select.tsx) +} + +export const clusterContext: ClusterContext = { + get cluster(): Cluster | null { + return getHostedCluster(); + }, + + get allNamespaces(): string[] { + return this.cluster?.allowedNamespaces ?? []; + }, + + get contextNamespaces(): string[] { + return namespaceStore.contextNamespaces ?? []; + }, +}; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index de78609813..f47ba1702e 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -40,7 +40,7 @@ interface Props { @observer export class Sidebar extends React.Component { async componentDidMount() { - crdStore.loadAllFromContextNamespaces(); + crdStore.reloadAll(); } renderCustomResources() { diff --git a/src/renderer/item.store.ts b/src/renderer/item.store.ts index f7a4e0e94e..ce2c5eac25 100644 --- a/src/renderer/item.store.ts +++ b/src/renderer/item.store.ts @@ -9,7 +9,7 @@ export interface ItemObject { @autobind() export abstract class ItemStore { - abstract loadAll(...args: any[]): Promise; + abstract loadAll(...args: any[]): Promise; protected defaultSorting = (item: T) => item.getName(); diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 3eb4d0c147..cd67cdf082 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -1,7 +1,6 @@ -import type { Cluster } from "../main/cluster"; -import type { NamespaceStore } from "./components/+namespaces/namespace.store"; +import type { ClusterContext } from "./components/context"; -import { action, computed, observable, reaction } from "mobx"; +import { action, observable, reaction, when } from "mobx"; import { autobind } from "./utils"; import { KubeObject } from "./api/kube-object"; import { IKubeWatchEvent, IKubeWatchMessage, kubeWatchApi } from "./api/kube-watch-api"; @@ -17,44 +16,23 @@ export interface KubeObjectStoreLoadingParams { @autobind() export abstract class KubeObjectStore extends ItemStore { + @observable static defaultContext: ClusterContext; // TODO: support multiple cluster contexts + abstract api: KubeApi; public readonly limit?: number; public readonly bufferSize: number = 50000; - @observable.ref protected cluster: Cluster; + + contextReady = when(() => Boolean(this.context)); + + get context(): ClusterContext { + return KubeObjectStore.defaultContext; + } constructor() { super(); this.bindWatchEventsUpdater(); } - // TODO: detach / remove circular dependency - @observable.ref private namespaceStore: NamespaceStore; - - protected async resolveNamespaceStore(): Promise { - const { namespaceStore } = await import("./components/+namespaces/namespace.store"); - - this.namespaceStore = namespaceStore; - - return namespaceStore; - } - - protected async resolveCluster(): Promise { - const { getHostedCluster, clusterStore } = await import("../common/cluster-store"); - - await clusterStore.whenLoaded; - this.cluster = getHostedCluster(); - await this.cluster.whenReady; - - return this.cluster; - } - - // TODO: figure out how to transparently replace with this.items - @computed get contextItems(): T[] { - const contextNamespaces = this.namespaceStore?.contextNamespaces ?? []; // not loaded - - return this.items.filter((item: T) => !item.getNs() || contextNamespaces.includes(item.getId())); - } - get query(): IKubeApiQueryParams { const { limit } = this; @@ -111,9 +89,7 @@ export abstract class KubeObjectStore extends ItemSt } protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise { - const cluster = await this.resolveCluster(); - - if (cluster.isAllowedResource(api.kind)) { + if (this.context?.cluster.isAllowedResource(api.kind)) { if (api.isNamespaced) { return Promise .all(namespaces.map(namespace => api.list({ namespace }))) @@ -131,21 +107,24 @@ export abstract class KubeObjectStore extends ItemSt } @action - async loadAll(namespaces?: string[], { replace = false /*partial update*/ } = {}): Promise { + async loadAll({ namespaces = [], merge = true } = {}): Promise { + await this.contextReady; this.isLoading = true; try { - // load all available namespaces by default - if (!namespaces?.length) { - const namespaceStore = await this.resolveNamespaceStore(); - - namespaces = namespaceStore.allowedNamespaces; // load all by default if list not provided + if (!namespaces.length) { + namespaces = this.context.allNamespaces; // load all available namespaces by default } const items = await this.loadItems({ namespaces, api: this.api }); - this.mergeItems(items, { replace }); this.isLoaded = true; + + if (merge) { + this.mergeItems(items); + } else { + return items; + } } catch (error) { console.error("Loading store items failed", { error, store: this }); this.resetOnError(error); @@ -155,18 +134,28 @@ export abstract class KubeObjectStore extends ItemSt } @action - mergeItems(partialItems: T[], { replace = false, updateStore = true, sort = true, filter = true } = {}): T[] { + reloadAll(opts: { namespaces?: string[], merge?: boolean, force?: boolean } = {}) { + const { force = false, ...loadingOptions } = opts; + + if (this.isLoading || (this.isLoaded && !force)) { + return; + } + + return this.loadAll(loadingOptions); + } + + @action + mergeItems(partialItems: T[], { replace = true, updateStore = true, sort = true, filter = true } = {}): T[] { let items = partialItems; + // update existing items if (!replace) { - items = this.items.toJS(); + const partialIds = partialItems.map(item => item.getId()); - partialItems.forEach(item => { - const index = items.findIndex(i => i.getId() === item.getId()); - - if (index < 0) items.push(item); // add - else items[index] = item; // update - }); + items = [ + ...this.items.filter(existingItem => !partialIds.includes(existingItem.getId())), + ...partialItems, + ]; } if (filter) items = this.filterItemsOnLoad(items); @@ -176,10 +165,6 @@ export abstract class KubeObjectStore extends ItemSt return items; } - async loadAllFromContextNamespaces(): Promise { - return this.loadAll(this.namespaceStore?.contextNamespaces); - } - protected resetOnError(error: any) { if (error) this.reset(); }