From eb45f45a7ab06a0c3a4b8c30e219ad7241abd53c Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 17 Jun 2021 12:19:22 -0400 Subject: [PATCH] Add context menu entry for deleting local clusters (#2923) --- src/common/__tests__/cluster-store.test.ts | 51 ++++------ .../catalog-entities/kubernetes-cluster.ts | 49 +++++----- src/common/catalog/catalog-entity.ts | 5 + src/common/cluster-ipc.ts | 1 + src/common/cluster-store.ts | 79 +++------------- src/common/hotbar-store.ts | 21 ++++- src/common/utils/index.ts | 5 +- src/main/__test__/cluster.test.ts | 4 +- src/main/catalog-pusher.ts | 2 +- .../__test__/kubeconfig-sync.test.ts | 4 + src/main/catalog-sources/kubeconfig-sync.ts | 5 +- src/main/cluster-manager.ts | 28 ++++-- src/main/cluster.ts | 19 ++-- src/main/helm/helm-release-manager.ts | 5 +- src/main/index.ts | 9 +- src/main/initializers/ipc.ts | 29 +++++- src/main/kubectl.ts | 18 ++-- src/main/promise-exec.ts | 3 +- src/main/proxy/lens-proxy.ts | 8 +- src/main/resource-applier.ts | 14 +-- src/migrations/cluster-store/3.6.0-beta.1.ts | 93 +++++++++---------- .../+catalog/catalog-entity-details.tsx | 70 ++++++-------- .../+catalog/catalog-entity-drawer-menu.tsx | 15 +-- .../+catalog/catalog-entity.store.ts | 19 +++- .../components/+catalog/catalog.module.css | 2 +- src/renderer/components/+catalog/catalog.tsx | 72 +++++++------- .../components/hotbar/hotbar-entity-icon.tsx | 2 +- .../components/hotbar/hotbar-icon.tsx | 21 ++++- src/renderer/ipc/index.tsx | 6 +- .../ipc/invalid-kubeconfig-handler.tsx | 66 ------------- 30 files changed, 348 insertions(+), 377 deletions(-) delete mode 100644 src/renderer/ipc/invalid-kubeconfig-handler.tsx diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 3b87f53670..92839bcb30 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -125,33 +125,28 @@ describe("empty config", () => { expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); }); - - it("removes cluster from store", async () => { - await ClusterStore.getInstance().removeById("foo"); - expect(ClusterStore.getInstance().getById("foo")).toBeNull(); - }); }); describe("with prod and dev clusters added", () => { beforeEach(() => { - ClusterStore.getInstance().addClusters( - new Cluster({ - id: "prod", - contextName: "foo", - preferences: { - clusterName: "prod" - }, - kubeConfigPath: embed("prod", kubeconfig) - }), - new Cluster({ - id: "dev", - contextName: "foo2", - preferences: { - clusterName: "dev" - }, - kubeConfigPath: embed("dev", kubeconfig) - }) - ); + const store = ClusterStore.getInstance(); + + store.addCluster({ + id: "prod", + contextName: "foo", + preferences: { + clusterName: "prod" + }, + kubeConfigPath: embed("prod", kubeconfig) + }); + store.addCluster({ + id: "dev", + contextName: "foo2", + preferences: { + clusterName: "dev" + }, + kubeConfigPath: embed("dev", kubeconfig) + }); }); it("check if store can contain multiple clusters", () => { @@ -222,16 +217,6 @@ describe("config with existing clusters", () => { expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); - it("allows to delete a cluster", () => { - ClusterStore.getInstance().removeById("cluster2"); - const storedCluster = ClusterStore.getInstance().getById("cluster1"); - - expect(storedCluster).toBeTruthy(); - const storedCluster2 = ClusterStore.getInstance().getById("cluster2"); - - expect(storedCluster2).toBeNull(); - }); - it("allows getting all of the clusters", async () => { const storedClusters = ClusterStore.getInstance().clustersList; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 82582d2bc5..c86ad91034 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -21,13 +21,13 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { CatalogEntity, CatalogEntityActionContext, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; +import { clusterActivateHandler, clusterDeleteHandler, clusterDisconnectHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store"; import { requestMain } from "../ipc"; -import { productName } from "../vars"; import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { addClusterURL } from "../routes"; import { app } from "electron"; +import { HotbarStore } from "../hotbar-store"; export type KubernetesClusterPrometheusMetrics = { address?: { @@ -50,7 +50,7 @@ export type KubernetesClusterSpec = { }; export interface KubernetesClusterStatus extends CatalogEntityStatus { - phase: "connected" | "disconnected"; + phase: "connected" | "disconnected" | "deleting"; } export class KubernetesCluster extends CatalogEntity { @@ -103,39 +103,38 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) - }); - } - - if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { - context.menuItems.push({ - title: "Delete", - icon: "delete", - onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), - confirm: { - message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?` - } - }); + context.menuItems.push( + { + title: "Settings", + icon: "edit", + onClick: () => context.navigate(`/entity/${this.metadata.uid}/settings`) + }, + { + title: "Delete", + icon: "delete", + onClick: () => { + HotbarStore.getInstance().removeAllHotbarItems(this.getId()); + requestMain(clusterDeleteHandler, this.metadata.uid); + }, + confirm: { + // TODO: change this to be a

tag with better formatting once this code can accept it. + message: `Delete the "${this.metadata.name}" context from "${this.metadata.labels.file}"?` + } + }, + ); } if (this.status.phase == "connected") { context.menuItems.push({ title: "Disconnect", icon: "link_off", - onClick: async () => { - requestMain(clusterDisconnectHandler, this.metadata.uid); - } + onClick: () => requestMain(clusterDisconnectHandler, this.metadata.uid) }); } else { context.menuItems.push({ title: "Connect", icon: "link", - onClick: async () => { - context.navigate(`/cluster/${this.metadata.uid}`); - } + onClick: () => context.navigate(`/cluster/${this.metadata.uid}`) }); } diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 9acc2a47c9..8b8d04f6cb 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -86,6 +86,11 @@ export interface CatalogEntityMetadata { export interface CatalogEntityStatus { phase: string; reason?: string; + + /** + * @default true + */ + enabled?: boolean; message?: string; active?: boolean; } diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 13725b37dc..9191d93048 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -24,5 +24,6 @@ export const clusterSetFrameIdHandler = "cluster:set-frame-id"; export const clusterVisibilityHandler = "cluster:visibility"; export const clusterRefreshHandler = "cluster:refresh"; export const clusterDisconnectHandler = "cluster:disconnect"; +export const clusterDeleteHandler = "cluster:delete"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index d2fcd616ed..01ff9b3fa1 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -20,8 +20,7 @@ */ import path from "path"; -import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron"; -import { unlink } from "fs-extra"; +import { app, ipcMain, ipcRenderer, webFrame } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction } from "mobx"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; @@ -30,7 +29,7 @@ import * as uuid from "uuid"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; import { ipcMainHandle, ipcMainOn, ipcRendererOn, requestMain } from "./ipc"; -import { disposer, noop, toJS } from "./utils"; +import { disposer, toJS } from "./utils"; export interface ClusterIconUpload { clusterId: string; @@ -83,9 +82,6 @@ export interface ClusterModel { /** List of accessible namespaces */ accessibleNamespaces?: string[]; - - /** @deprecated */ - kubeConfig?: string; // yaml } export interface ClusterPreferences extends ClusterPrometheusPreferences { @@ -113,7 +109,7 @@ export class ClusterStore extends BaseStore { private static StateChannel = "cluster:state"; static get storedKubeConfigFolder(): string { - return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); + return path.resolve(app.getPath("userData"), "kubeconfigs"); } static getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string { @@ -123,7 +119,6 @@ export class ClusterStore extends BaseStore { @observable clusters = observable.map(); @observable removedClusters = observable.map(); - private static stateRequestChannel = "cluster:states"; protected disposer = disposer(); constructor() { @@ -142,35 +137,22 @@ export class ClusterStore extends BaseStore { } async load() { + const initialStates = "cluster:states"; + await super.load(); - type clusterStateSync = { - id: string; - state: ClusterState; - }; if (ipcRenderer) { logger.info("[CLUSTER-STORE] requesting initial state sync"); - const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel); - clusterStates.forEach((clusterState) => { - const cluster = this.getById(clusterState.id); - - if (cluster) { - cluster.setState(clusterState.state); - } - }); + for (const { id, state } of await requestMain(initialStates)) { + this.getById(id)?.setState(state); + } } else if (ipcMain) { - ipcMainHandle(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { - const clusterStates: clusterStateSync[] = []; - - this.clustersList.forEach((cluster) => { - clusterStates.push({ - state: cluster.getState(), - id: cluster.id - }); - }); - - return clusterStates; + ipcMainHandle(initialStates, () => { + return this.clustersList.map(cluster => ({ + id: cluster.id, + state: cluster.getState(), + })); }); } } @@ -178,9 +160,7 @@ export class ClusterStore extends BaseStore { protected pushStateToViewsAutomatically() { if (ipcMain) { this.disposer.push( - reaction(() => this.connectedClustersList, () => { - this.pushState(); - }), + reaction(() => this.connectedClustersList, () => this.pushState()), ); } } @@ -229,18 +209,6 @@ export class ClusterStore extends BaseStore { return this.clusters.get(id) ?? null; } - @action - addClusters(...models: ClusterModel[]): Cluster[] { - const clusters: Cluster[] = []; - - models.forEach(model => { - clusters.push(this.addCluster(model)); - }); - - return clusters; - } - - @action addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); @@ -253,25 +221,6 @@ export class ClusterStore extends BaseStore { return cluster; } - async removeCluster(model: ClusterModel) { - await this.removeById(model.id); - } - - @action - async removeById(clusterId: ClusterId) { - appEventBus.emit({ name: "cluster", action: "remove" }); - const cluster = this.getById(clusterId); - - if (cluster) { - this.clusters.delete(clusterId); - - // remove only custom kubeconfigs (pasted as text) - if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { - await unlink(cluster.kubeConfigPath).catch(noop); - } - } - } - @action protected fromStore({ clusters = [] }: ClusterStoreModel = {}) { const currentClusters = new Map(this.clusters); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 2d91ec0cc5..6d15ace11e 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -181,7 +181,7 @@ export class HotbarStore extends BaseStore { } @action - removeFromHotbar(uid: string) { + removeFromHotbar(uid: string): void { const hotbar = this.getActive(); const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); @@ -192,6 +192,25 @@ export class HotbarStore extends BaseStore { hotbar.items[index] = null; } + /** + * Remvove all hotbar items that reference the `uid`. + * @param uid The `EntityId` that each hotbar item refers to + * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. + */ + @action + removeAllHotbarItems(uid: string) { + const undoItems: [Hotbar, number, HotbarItem][] = []; + + for (const hotbar of this.hotbars) { + const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); + + if (index >= 0) { + undoItems.push([hotbar, index, hotbar.items[index]]); + hotbar.items[index] = null; + } + } + } + findClosestEmptyIndex(from: number, direction = 1) { let index = from; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 34bd65755b..d652933341 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -19,8 +19,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Common utils (main OR renderer) - +/** + * A function that does nothing + */ export function noop(...args: T): void { return void args; } diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 9210739195..8c50518c85 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -120,8 +120,8 @@ describe("create clusters", () => { protected bindEvents() { return; } - protected async ensureKubectl() { - return Promise.resolve(true); + async ensureKubectl() { + return Promise.resolve(null); } }({ id: "foo", diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index 5c9a41b702..b662c36f69 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -30,7 +30,7 @@ import type { CatalogEntity } from "../common/catalog"; const broadcaster = debounce((items: CatalogEntity[]) => { broadcastMessage("catalog:items", items); -}, 1_000, { trailing: true }); +}, 1_000, { leading: true, trailing: true }); export function pushCatalogToRenderer(catalog: CatalogEntityRegistry) { return reaction(() => toJS(catalog.items), (items) => { diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 7fcd3c1db6..ac0eb3b33b 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -27,6 +27,7 @@ import { computeDiff, configToModels } from "../kubeconfig-sync"; import mockFs from "mock-fs"; import fs from "fs"; import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterManager } from "../../cluster-manager"; jest.mock("electron", () => ({ app: { @@ -38,10 +39,13 @@ describe("kubeconfig-sync.source tests", () => { beforeEach(() => { mockFs(); ClusterStore.createInstance(); + ClusterManager.createInstance(); }); afterEach(() => { mockFs.restore(); + ClusterStore.resetInstance(); + ClusterManager.resetInstance(); }); describe("configsToModels", () => { diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index af6d7e150d..6a6f4120f9 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -31,7 +31,7 @@ import logger from "../logger"; import type { KubeConfig } from "@kubernetes/client-node"; import { loadConfigFromString, splitConfig } from "../../common/kube-helpers"; import { Cluster } from "../cluster"; -import { catalogEntityFromCluster } from "../cluster-manager"; +import { catalogEntityFromCluster, ClusterManager } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; import { createHash } from "crypto"; @@ -170,6 +170,9 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri // remove and disconnect clusters that were removed from the config if (!model) { + // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting + ClusterManager.getInstance().deleting.delete(value[0].id); + value[0].disconnect(); source.delete(contextName); logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 4075c81e36..d42b2a6002 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -21,8 +21,8 @@ import "../common/cluster-ipc"; import type http from "http"; -import { action, autorun, makeObservable, reaction, toJS } from "mobx"; -import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; +import { action, autorun, makeObservable, observable, observe, reaction, toJS } from "mobx"; +import { ClusterId, ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; import type { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; @@ -30,17 +30,18 @@ import { Singleton } from "../common/utils"; import { catalogEntityRegistry } from "./catalog"; import { KubernetesCluster, KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; import { ipcMainOn } from "../common/ipc"; +import { once } from "lodash"; export class ClusterManager extends Singleton { private store = ClusterStore.getInstance(); + deleting = observable.set(); constructor() { super(); makeObservable(this); - this.bindEvents(); } - private bindEvents() { + init = once(() => { // reacting to every cluster's state change and total amount of items reaction( () => this.store.clustersList.map(c => c.getState()), @@ -59,6 +60,12 @@ export class ClusterManager extends Singleton { this.syncClustersFromCatalog(entities); }); + observe(this.deleting, change => { + if (change.type === "add") { + this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue)); + } + }); + // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(this.store.removedClusters.values()); @@ -76,7 +83,7 @@ export class ClusterManager extends Singleton { ipcMainOn("network:offline", this.onNetworkOffline); ipcMainOn("network:online", this.onNetworkOnline); - } + }); @action protected updateCatalog(clusters: Cluster[]) { @@ -115,8 +122,15 @@ export class ClusterManager extends Singleton { catalogEntityRegistry.items.splice(index, 1, entity); } - protected updateEntityStatus(entity: KubernetesCluster, cluster: Cluster) { - entity.status.phase = cluster.accessible ? "connected" : "disconnected"; + @action + protected updateEntityStatus(entity: KubernetesCluster, cluster?: Cluster) { + if (this.deleting.has(entity.getId())) { + entity.status.phase = "deleting"; + entity.status.enabled = false; + } else { + entity.status.phase = cluster?.accessible ? "connected" : "disconnected"; + entity.status.enabled = true; + } } @action syncClustersFromCatalog(entities: KubernetesCluster[]) { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index df0da0a552..56c07a26b0 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -88,12 +88,7 @@ export interface ClusterState { export class Cluster implements ClusterModel, ClusterState { /** Unique id for a cluster */ public readonly id: ClusterId; - /** - * Kubectl - * - * @internal - */ - public kubeCtl: Kubectl; + private kubeCtl: Kubectl; /** * Context handler * @@ -363,7 +358,7 @@ export class Cluster implements ClusterModel, ClusterState { if (this.accessible) { await this.refreshAccessibility(); - this.ensureKubectl(); + this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard } this.activated = true; @@ -373,10 +368,12 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - protected async ensureKubectl() { - this.kubeCtl = new Kubectl(this.version); + async ensureKubectl() { + this.kubeCtl ??= new Kubectl(this.version); - return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard + await this.kubeCtl.ensureKubectl(); + + return this.kubeCtl; } /** @@ -650,7 +647,7 @@ export class Cluster implements ClusterModel, ClusterState { const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api); try { - const { body: { items }} = await api.listNamespace(); + const { body: { items } } = await api.listNamespace(); const namespaces = items.map(ns => ns.metadata.name); this.getAllowedNamespacesErrorCount = 0; // reset on success diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 9ec1237c07..b8a18a1021 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -169,9 +169,10 @@ export async function rollback(name: string, namespace: string, revision: number async function getResources(name: string, namespace: string, cluster: Cluster) { try { const helm = await helmCli.binaryPath(); - const kubectl = await cluster.kubeCtl.getPath(); + const kubectl = await cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); const pathToKubeconfig = await cluster.getProxyKubeconfigPath(); - const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`); + const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectlPath}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`); return JSON.parse(stdout).items; } catch { diff --git a/src/main/index.ts b/src/main/index.ts index e04746e1e2..aba3d73ce1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -146,9 +146,12 @@ app.on("ready", async () => { filesystemStore.load(), ]); - const lensProxy = LensProxy.createInstance(handleWsUpgrade); - - ClusterManager.createInstance(); + const lensProxy = LensProxy.createInstance( + handleWsUpgrade, + req => ClusterManager.getInstance().getClusterForRequest(req), + ); + + ClusterManager.createInstance().init(); KubeconfigSyncManager.createInstance(); try { diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index e3bb4f5ff7..95c4571a41 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -22,11 +22,15 @@ import type { IpcMainInvokeEvent } from "electron"; import type { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../../common/cluster-ipc"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler } from "../../common/cluster-ipc"; import { ClusterId, ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; import { ipcMainHandle } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; +import { ClusterManager } from "../cluster-manager"; +import { bundledKubectlPath } from "../kubectl"; +import logger from "../logger"; +import { promiseExecFile } from "../promise-exec"; import { ResourceApplier } from "../resource-applier"; export function initIpcMainHandlers() { @@ -73,6 +77,29 @@ export function initIpcMainHandlers() { } }); + ipcMainHandle(clusterDeleteHandler, async (event, clusterId: ClusterId) => { + appEventBus.emit({ name: "cluster", action: "remove" }); + const cluster = ClusterStore.getInstance().getById(clusterId); + + if (!cluster) { + return; + } + + ClusterManager.getInstance().deleting.add(clusterId); + cluster.disconnect(); + clusterFrameMap.delete(cluster.id); + const kubectlPath = bundledKubectlPath(); + const args = ["config", "delete-context", cluster.contextName, "--kubeconfig", cluster.kubeConfigPath]; + + try { + await promiseExecFile(kubectlPath, args); + } catch ({ stderr }) { + logger.error(`[CLUSTER-REMOVE]: failed to remove cluster: ${stderr}`, { clusterId, context: cluster.contextName }); + + throw `Failed to remove cluster: ${stderr}`; + } + }); + ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { appEventBus.emit({ name: "cluster", action: "kubectl-apply-all" }); const cluster = ClusterStore.getInstance().getById(clusterId); diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index 1a00d2919d..01b38c67fe 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -31,6 +31,7 @@ import { UserStore } from "../common/user-store"; import { customRequest } from "../common/request"; import { getBundledKubectlVersion } from "../common/utils/app-version"; import { isDevelopment, isWindows, isTestEnv } from "../common/vars"; +import { SemVer } from "semver"; const bundledVersion = getBundledKubectlVersion(); const kubectlMap: Map = new Map([ @@ -92,14 +93,19 @@ export class Kubectl { // Returns the single bundled Kubectl instance public static bundled() { - if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion); - - return Kubectl.bundledInstance; + return Kubectl.bundledInstance ??= new Kubectl(Kubectl.bundledKubectlVersion); } constructor(clusterVersion: string) { - const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion); - const minorVersion = versionParts[1]; + let version: SemVer; + + try { + version = new SemVer(clusterVersion, { includePrerelease: false }); + } catch { + version = new SemVer(Kubectl.bundledKubectlVersion); + } + + const minorVersion = `${version.major}.${version.minor}`; /* minorVersion is the first two digits of kube server version if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ @@ -107,7 +113,7 @@ export class Kubectl { this.kubectlVersion = kubectlMap.get(minorVersion); logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using version map`); } else { - this.kubectlVersion = versionParts[1] + versionParts[2]; + this.kubectlVersion = version.format(); logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using fallback`); } diff --git a/src/main/promise-exec.ts b/src/main/promise-exec.ts index 8c55272c60..8b9d5b72cc 100644 --- a/src/main/promise-exec.ts +++ b/src/main/promise-exec.ts @@ -20,6 +20,7 @@ */ import * as util from "util"; -import { exec } from "child_process"; +import { exec, execFile } from "child_process"; export const promiseExec = util.promisify(exec); +export const promiseExecFile = util.promisify(execFile); diff --git a/src/main/proxy/lens-proxy.ts b/src/main/proxy/lens-proxy.ts index 199d48398b..00ef5fbb48 100644 --- a/src/main/proxy/lens-proxy.ts +++ b/src/main/proxy/lens-proxy.ts @@ -29,7 +29,7 @@ import { Router } from "../router"; import type { ContextHandler } from "../context-handler"; import logger from "../logger"; import { Singleton } from "../../common/utils"; -import { ClusterManager } from "../cluster-manager"; +import type { Cluster } from "../cluster"; type WSUpgradeHandler = (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => void; @@ -42,7 +42,7 @@ export class LensProxy extends Singleton { public port: number; - constructor(handleWsUpgrade: WSUpgradeHandler) { + constructor(handleWsUpgrade: WSUpgradeHandler, protected getClusterForRequest: (req: http.IncomingMessage) => Cluster | undefined) { super(); const proxy = this.createProxy(); @@ -104,7 +104,7 @@ export class LensProxy extends Singleton { } protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const cluster = this.getClusterForRequest(req); if (cluster) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); @@ -220,7 +220,7 @@ export class LensProxy extends Singleton { } protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const cluster = this.getClusterForRequest(req); if (cluster) { const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 5dd02cc617..f4772c5275 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -36,15 +36,15 @@ export class ResourceApplier { async apply(resource: KubernetesObject | any): Promise { resource = this.sanitizeObject(resource); - appEventBus.emit({name: "resource", action: "apply"}); + appEventBus.emit({ name: "resource", action: "apply" }); return await this.kubectlApply(yaml.safeDump(resource)); } protected async kubectlApply(content: string): Promise { - const { kubeCtl } = this.cluster; - const kubectlPath = await kubeCtl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); return new Promise((resolve, reject) => { const fileName = tempy.file({ name: "resource.yaml" }); @@ -82,9 +82,9 @@ export class ResourceApplier { } protected async kubectlCmdAll(subCmd: string, resources: string[], args: string[] = []): Promise { - const { kubeCtl } = this.cluster; - const kubectlPath = await kubeCtl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); return new Promise((resolve, reject) => { const tmpDir = tempy.directory(); diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index 5339d5232b..516e025eb6 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -23,69 +23,68 @@ // convert file path cluster icons to their base64 encoded versions import path from "path"; -import { app, remote } from "electron"; +import { app } from "electron"; import { migration } from "../migration-wrapper"; import fse from "fs-extra"; import { ClusterModel, ClusterStore } from "../../common/cluster-store"; import { loadConfigFromFileSync } from "../../common/kube-helpers"; +interface Pre360ClusterModel extends ClusterModel { + kubeConfig: string; +} + export default migration({ version: "3.6.0-beta.1", run(store, printLog) { - const userDataPath = (app || remote.app).getPath("userData"); - const kubeConfigBase = ClusterStore.getCustomKubeConfigPath(""); - const storedClusters: ClusterModel[] = store.get("clusters") || []; + const userDataPath = app.getPath("userData"); + const storedClusters: Pre360ClusterModel[] = store.get("clusters") ?? []; + const migratedClusters: ClusterModel[] = []; - if (!storedClusters.length) return; - fse.ensureDirSync(kubeConfigBase); + fse.ensureDirSync(ClusterStore.storedKubeConfigFolder); printLog("Number of clusters to migrate: ", storedClusters.length); - const migratedClusters = storedClusters - .map(cluster => { - /** - * migrate kubeconfig - */ - try { - const absPath = ClusterStore.getCustomKubeConfigPath(cluster.id); - fse.ensureDirSync(path.dirname(absPath)); - fse.writeFileSync(absPath, cluster.kubeConfig, { encoding: "utf-8", mode: 0o600 }); - // take the embedded kubeconfig and dump it into a file - cluster.kubeConfigPath = absPath; - cluster.contextName = loadConfigFromFileSync(cluster.kubeConfigPath).config.getCurrentContext(); - delete cluster.kubeConfig; + for (const clusterModel of storedClusters) { + /** + * migrate kubeconfig + */ + try { + const absPath = ClusterStore.getCustomKubeConfigPath(clusterModel.id); - } catch (error) { - printLog(`Failed to migrate Kubeconfig for cluster "${cluster.id}", removing cluster...`, error); + // take the embedded kubeconfig and dump it into a file + fse.writeFileSync(absPath, clusterModel.kubeConfig, { encoding: "utf-8", mode: 0o600 }); - return undefined; + clusterModel.kubeConfigPath = absPath; + clusterModel.contextName = loadConfigFromFileSync(clusterModel.kubeConfigPath).config.getCurrentContext(); + delete clusterModel.kubeConfig; + + } catch (error) { + printLog(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error); + + continue; + } + + /** + * migrate cluster icon + */ + try { + if (clusterModel.preferences?.icon) { + printLog(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`); + const iconPath = clusterModel.preferences.icon.replace("store://", ""); + const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); + + clusterModel.preferences.icon = `data:;base64,${fileData.toString("base64")}`; + } else { + delete clusterModel.preferences?.icon; } + } catch (error) { + printLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); + delete clusterModel.preferences.icon; + } - /** - * migrate cluster icon - */ - try { - if (cluster.preferences?.icon) { - printLog(`migrating ${cluster.preferences.icon} for ${cluster.preferences.clusterName}`); - const iconPath = cluster.preferences.icon.replace("store://", ""); - const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); - - cluster.preferences.icon = `data:;base64,${fileData.toString("base64")}`; - } else { - delete cluster.preferences?.icon; - } - } catch (error) { - printLog(`Failed to migrate cluster icon for cluster "${cluster.id}"`, error); - delete cluster.preferences.icon; - } - - return cluster; - }) - .filter(c => c); - - // "overwrite" the cluster configs - if (migratedClusters.length > 0) { - store.set("clusters", migratedClusters); + migratedClusters.push(clusterModel); } + + store.set("clusters", migratedClusters); } }); diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index 639d7c4843..c36a227695 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -26,28 +26,18 @@ import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer"; import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; import type { CatalogCategory } from "../../../common/catalog"; import { Icon } from "../icon"; -import { KubeObject } from "../../api/kube-object"; import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { HotbarIcon } from "../hotbar/hotbar-icon"; +import type { CatalogEntityItem } from "./catalog-entity.store"; -interface Props { - entity: CatalogEntity; +interface Props { + item: CatalogEntityItem | null | undefined; hideDetails(): void; } @observer -export class CatalogEntityDetails extends Component { - private abortController?: AbortController; - - constructor(props: Props) { - super(props); - } - - componentWillUnmount() { - this.abortController?.abort(); - } - +export class CatalogEntityDetails extends Component> { categoryIcon(category: CatalogCategory) { if (category.metadata.icon.includes("; @@ -56,16 +46,10 @@ export class CatalogEntityDetails extends Component { } } - openEntity() { - this.props.entity.onRun(catalogEntityRunContext); - } - - renderContent() { - const { entity } = this.props; - const labels = KubeObject.stringifyLabels(entity.metadata.labels); - const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(entity.kind, entity.apiVersion); - const details = detailItems.map((item, index) => { - return ; + renderContent(item: CatalogEntityItem) { + const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(item.kind, item.apiVersion); + const details = detailItems.map(({ components }, index) => { + return ; }); const showDetails = detailItems.find((item) => item.priority > 999) === undefined; @@ -76,29 +60,35 @@ export class CatalogEntityDetails extends Component {

this.openEntity()} + uid={item.id} + title={item.name} + source={item.source} + icon={item.entity.spec.iconData} + disabled={!item?.enabled} + onClick={() => item.onRun(catalogEntityRunContext)} size={128} /> -
- Click to open -
+ {item?.enabled && ( +
+ Click to open +
+ )}
- {entity.metadata.name} + {item.name} - {entity.kind} + {item.kind} - {entity.metadata.source} + {item.source} + + + {item.phase}
@@ -111,8 +101,8 @@ export class CatalogEntityDetails extends Component { } render() { - const { entity, hideDetails } = this.props; - const title = `${entity.kind}: ${entity.metadata.name}`; + const { item, hideDetails } = this.props; + const title = `${item.kind}: ${item.name}`; return ( { usePortal={true} open={true} title={title} - toolbar={} + toolbar={} onClose={hideDetails} > - {this.renderContent()} + {item && this.renderContent(item)} ); } diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index d4f75d7434..545eebb933 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -30,9 +30,10 @@ import { MenuItem } from "../menu"; import { ConfirmDialog } from "../confirm-dialog"; import { HotbarStore } from "../../../common/hotbar-store"; import { Icon } from "../icon"; +import type { CatalogEntityItem } from "./catalog-entity.store"; export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { - entity: T | null | undefined; + item: CatalogEntityItem | null | undefined; } @observer @@ -47,9 +48,9 @@ export class CatalogEntityDrawerMenu extends React.Comp componentDidMount() { this.contextMenu = { menuItems: [], - navigate: (url: string) => navigate(url) + navigate: (url: string) => navigate(url), }; - this.props.entity?.onContextMenuOpen(this.contextMenu); + this.props.item?.onContextMenuOpen(this.contextMenu); } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -107,19 +108,19 @@ export class CatalogEntityDrawerMenu extends React.Comp } render() { - if (!this.contextMenu) { + const { className, item: entity, ...menuProps } = this.props; + + if (!this.contextMenu || !entity.enabled) { return null; } - const { className, entity, ...menuProps } = this.props; - return ( - {this.getMenuItems(entity)} + {this.getMenuItems(entity.entity)} ); } diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts index 7c63bcb0f0..c9e0e9bf11 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -25,13 +25,17 @@ import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalo import { ItemObject, ItemStore } from "../../item.store"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; import { autoBind } from "../../../common/utils"; -export class CatalogEntityItem implements ItemObject { - constructor(public entity: CatalogEntity) {} +export class CatalogEntityItem implements ItemObject { + constructor(public entity: T) {} get kind() { return this.entity.kind; } + get apiVersion() { + return this.entity.apiVersion; + } + get name() { return this.entity.metadata.name; } @@ -52,6 +56,10 @@ export class CatalogEntityItem implements ItemObject { return this.entity.status.phase; } + get enabled() { + return this.entity.status.enabled ?? true; + } + get labels() { const labels: string[] = []; @@ -87,7 +95,7 @@ export class CatalogEntityItem implements ItemObject { } } -export class CatalogEntityStore extends ItemStore { +export class CatalogEntityStore extends ItemStore> { constructor() { super(); makeObservable(this); @@ -95,6 +103,7 @@ export class CatalogEntityStore extends ItemStore { } @observable activeCategory?: CatalogCategory; + @observable selectedItemId?: string; @computed get entities() { if (!this.activeCategory) { @@ -104,6 +113,10 @@ export class CatalogEntityStore extends ItemStore { return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); } + @computed get selectedItem() { + return this.entities.find(e => e.getId() === this.selectedItemId); + } + watch() { const disposers: IReactionDisposer[] = [ reaction(() => this.entities, () => this.loadAll()), diff --git a/src/renderer/components/+catalog/catalog.module.css b/src/renderer/components/+catalog/catalog.module.css index cc1e7ce411..cfc6aa569a 100644 --- a/src/renderer/components/+catalog/catalog.module.css +++ b/src/renderer/components/+catalog/catalog.module.css @@ -44,7 +44,7 @@ color: var(--colorSuccess); } -.disconnected { +.disconnected, .deleting { color: var(--halfGray); } diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 067dab9009..13a827f398 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -32,7 +32,7 @@ import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from " import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; -import { catalogCategoryRegistry } from "../../../common/catalog"; +import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import { Notifications } from "../notifications"; @@ -59,7 +59,6 @@ export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; @observable private contextMenu: CatalogEntityContextMenuContext; @observable activeTab?: string; - @observable selectedItem?: CatalogEntityItem; constructor(props: Props) { super(props); @@ -79,7 +78,7 @@ export class Catalog extends React.Component { async componentDidMount() { this.contextMenu = { menuItems: observable.array([]), - navigate: (url: string) => navigate(url) + navigate: (url: string) => navigate(url), }; this.catalogEntityStore = new CatalogEntityStore(); disposeOnUnmount(this, [ @@ -103,13 +102,13 @@ export class Catalog extends React.Component { ]); } - addToHotbar(item: CatalogEntityItem): void { + addToHotbar(item: CatalogEntityItem): void { HotbarStore.getInstance().addToHotbar(item.entity); } - onDetails(item: CatalogEntityItem) { - this.selectedItem = item; - } + onDetails = (item: CatalogEntityItem) => { + this.catalogEntityStore.selectedItemId = item.getId(); + }; onMenuItemClick(menuItem: CatalogEntityContextMenu) { if (menuItem.confirm) { @@ -144,7 +143,7 @@ export class Catalog extends React.Component { return ; } - renderItemMenu = (item: CatalogEntityItem) => { + renderItemMenu = (item: CatalogEntityItem) => { const onOpen = () => { this.contextMenu.menuItems = []; @@ -167,7 +166,7 @@ export class Catalog extends React.Component { ); }; - renderIcon(item: CatalogEntityItem) { + renderIcon(item: CatalogEntityItem) { return ( { store={this.catalogEntityStore} tableId="catalog-items" sortingCallbacks={{ - [sortBy.name]: (item: CatalogEntityItem) => item.name, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, + [sortBy.name]: (item: CatalogEntityItem) => item.name, + [sortBy.source]: (item: CatalogEntityItem) => item.source, + [sortBy.status]: (item: CatalogEntityItem) => item.phase, }} searchFilters={[ - (entity: CatalogEntityItem) => entity.searchFields, + (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ { title: "", className: css.iconCell }, @@ -202,14 +201,17 @@ export class Catalog extends React.Component { { title: "Labels", className: css.labelsCell }, { title: "Status", className: css.statusCell, sortBy: sortBy.status }, ]} - renderTableContents={(item: CatalogEntityItem) => [ + customizeTableRowProps={(item: CatalogEntityItem) => ({ + disabled: !item.enabled, + })} + renderTableContents={(item: CatalogEntityItem) => [ this.renderIcon(item), item.name, item.source, item.labels.map((label) => ), { title: item.phase, className: cssNames(css[item.phase]) } ]} - onDetails={(item: CatalogEntityItem) => this.onDetails(item) } + onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} /> ); @@ -224,13 +226,13 @@ export class Catalog extends React.Component { store={this.catalogEntityStore} tableId="catalog-items" sortingCallbacks={{ - [sortBy.name]: (item: CatalogEntityItem) => item.name, - [sortBy.kind]: (item: CatalogEntityItem) => item.kind, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, + [sortBy.name]: (item: CatalogEntityItem) => item.name, + [sortBy.kind]: (item: CatalogEntityItem) => item.kind, + [sortBy.source]: (item: CatalogEntityItem) => item.source, + [sortBy.status]: (item: CatalogEntityItem) => item.phase, }} searchFilters={[ - (entity: CatalogEntityItem) => entity.searchFields, + (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ { title: "", className: css.iconCell }, @@ -240,7 +242,10 @@ export class Catalog extends React.Component { { title: "Labels", className: css.labelsCell }, { title: "Status", className: css.statusCell, sortBy: sortBy.status }, ]} - renderTableContents={(item: CatalogEntityItem) => [ + customizeTableRowProps={(item: CatalogEntityItem) => ({ + disabled: !item.enabled, + })} + renderTableContents={(item: CatalogEntityItem) => [ this.renderIcon(item), item.name, item.kind, @@ -248,8 +253,8 @@ export class Catalog extends React.Component { item.labels.map((label) => ), { title: item.phase, className: cssNames(css[item.phase]) } ]} - detailsItem={this.selectedItem} - onDetails={(item: CatalogEntityItem) => this.onDetails(item) } + detailsItem={this.catalogEntityStore.selectedItem} + onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} /> ); @@ -265,15 +270,18 @@ export class Catalog extends React.Component {
{ this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
- { !this.selectedItem && ( - - )} - { this.selectedItem && ( - this.selectedItem = null} - /> - )} + { + this.catalogEntityStore.selectedItem + ? ( + this.catalogEntityStore.selectedItemId = null} + /> + ) + : ( + + ) + } ); } diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index 5708379a7a..bde9347d39 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -54,7 +54,7 @@ export class HotbarEntityIcon extends React.Component { componentDidMount() { this.contextMenu = { menuItems: [], - navigate: (url: string) => navigate(url) + navigate: (url: string) => navigate(url), }; } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 0ee8f2ddc8..7d3008231e 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -62,7 +62,7 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) { } export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => { - const { uid, title, icon, active, className, source, disabled, onMenuOpen, children, ...rest } = props; + const { uid, title, icon, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props; const id = `hotbarIcon-${uid}`; const [menuOpen, setMenuOpen] = useState(false); @@ -77,7 +77,13 @@ export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: Hotba src={icon} className={active ? "active" : "default"} width={size} - height={size} />; + height={size} + onClick={(event) => { + if (!disabled) { + onClick?.(event); + } + }} + />; } else { return { + if (!disabled) { + onClick?.(event); + } + }} />; } }; @@ -106,8 +117,10 @@ export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: Hotba toggleEvent="contextmenu" position={{right: true, bottom: true }} // FIXME: position does not work open={() => { - onMenuOpen?.(); - toggleMenu(); + if (!disabled) { + onMenuOpen?.(); + toggleMenu(); + } }} close={() => toggleMenu()}> { menuItems.map((menuItem) => { diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 4b61205bd7..51a50f1b01 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -25,7 +25,6 @@ import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, Upda import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; -import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler"; import { ClusterStore } from "../../common/cluster-store"; import { navigate } from "../navigation"; import { entitySettingsURL } from "../../common/routes"; @@ -87,7 +86,7 @@ function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: if (!wasDisplayed || (now - lastDisplayedAt) > intervalBetweenNotifications) { listNamespacesForbiddenHandlerDisplayedAt.set(clusterId, now); - } else { + } else { // don't bother the user too often return; } @@ -100,7 +99,7 @@ function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: Add Accessible Namespaces

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

-
- - ), - { - id: notificationId, - timeout: 0 - } - ); -}