From 4d05eff051e3ff8152e5ad8e20d7a720a4be136b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 20 May 2021 18:20:52 -0400 Subject: [PATCH] Split Catalog Categories and Registries - Main and Renderer now have different types - No longer have unified class declarations - Move towards a computed model on main, with the CatalogEntityRegistry folding over the declared handlers Signed-off-by: Sebastian Malton finish design work, still doesn't compile Signed-off-by: Sebastian Malton --- src/common/__tests__/cluster-store.test.ts | 84 +++---- src/common/__tests__/hotbar-store.test.ts | 27 +-- .../catalog-entities/kubernetes-cluster.ts | 143 +---------- src/common/catalog-entities/web-link.ts | 49 +--- .../catalog/catalog-category-registry.ts | 142 +++++++---- src/common/catalog/catalog-entity.ts | 184 +++++--------- src/common/catalog/index.ts | 2 +- src/common/cluster-ipc.ts | 15 +- src/common/cluster-store.ts | 227 ++---------------- src/common/hotbar-store.ts | 2 +- src/common/utils/disposer.ts | 8 +- src/common/utils/extended-map.ts | 19 +- src/extensions/core-api/index.ts | 6 +- src/extensions/core-api/main/catalog.ts | 59 +++++ .../core-api/main/index.ts} | 2 +- src/extensions/core-api/renderer/catalog.ts | 71 ++++++ .../core-api/renderer/index.ts} | 22 +- src/extensions/lens-main-extension.ts | 16 +- src/extensions/registries/command-registry.ts | 2 +- .../registries/entity-setting-registry.ts | 2 +- src/extensions/renderer-api/k8s-api.ts | 2 +- src/main/catalog-pusher.ts | 7 +- .../__test__/kubeconfig-sync.test.ts | 14 +- src/main/catalog-sources/kubeconfig-sync.ts | 96 ++++---- .../__tests__/catalog-entity-registry.test.ts | 50 ++-- src/main/catalog/catalog-category-registry.ts | 108 +++++++++ src/main/catalog/catalog-entity-registry.ts | 103 +++++--- src/main/catalog/catalog-entity.ts | 40 +++ src/main/catalog/index.ts | 2 + src/main/cluster-manager.ts | 207 +++++++--------- src/main/cluster.ts | 8 +- src/main/index.ts | 13 +- src/main/initializers/catalog-categories.ts | 62 +++++ src/main/initializers/index.ts | 22 ++ src/migrations/cluster-store/3.6.0-beta.1.ts | 6 +- src/migrations/hotbar-store/5.0.0-alpha.0.ts | 4 +- src/migrations/hotbar-store/5.0.0-beta.5.ts | 4 +- src/renderer/bootstrap.tsx | 16 +- src/renderer/catalog-entities/index.ts | 22 ++ .../catalog-entities/kubernetes-cluster.ts | 43 ++++ .../catalog-entities/web-link.ts} | 20 +- .../__tests__/catalog-entity-registry.test.ts | 38 ++- src/renderer/catalog/catalog-categories.ts | 96 ++++++++ .../catalog/catalog-category-registry.ts | 165 +++++++++++++ .../catalog-entity-registry.ts | 26 +- src/renderer/catalog/catalog-entity.ts | 122 ++++++++++ src/renderer/catalog/index.ts | 25 ++ .../components/+add-cluster/add-cluster.tsx | 6 +- .../+catalog/catalog-add-button.tsx | 18 +- .../+catalog/catalog-entity.store.ts | 29 ++- src/renderer/components/+catalog/catalog.tsx | 64 ++--- .../components/+cluster/cluster-overview.tsx | 4 +- .../+entity-settings/entity-settings.tsx | 6 +- .../+network-ingresses/ingress-details.tsx | 4 +- .../components/+nodes/node-details.tsx | 4 +- .../volume-claim-details.tsx | 4 +- .../daemonset-details.tsx | 4 +- .../deployment-details.tsx | 4 +- .../+workloads-pods/pod-details-container.tsx | 4 +- .../+workloads-pods/pod-details.tsx | 4 +- .../replicaset-details.tsx | 4 +- .../statefulset-details.tsx | 4 +- .../cluster-manager/cluster-status.tsx | 7 +- .../cluster-manager/cluster-view.tsx | 19 +- .../components/cluster-manager/lens-views.ts | 17 +- .../cluster-settings.command.ts | 4 +- .../cluster-settings/cluster-settings.tsx | 6 +- .../command-palette/command-dialog.tsx | 4 +- .../components/hotbar/hotbar-entity-icon.tsx | 47 +--- .../components/hotbar/hotbar-icon.tsx | 34 +-- .../components/hotbar/hotbar-menu.tsx | 9 +- .../__test__/main-layout-header.test.tsx | 6 +- .../initializers/catalog-categories.ts | 138 +++++++++++ .../catalog-icons}/kubernetes.svg | 0 src/renderer/initializers/index.ts | 22 ++ src/renderer/ipc/index.tsx | 4 +- .../ipc/invalid-kubeconfig-handler.tsx | 8 +- .../k8s/resource-stack.ts | 14 +- src/renderer/lens-app.tsx | 9 +- src/renderer/protocol-handler/app-handlers.ts | 10 +- .../utils/__tests__/storageHelper.test.ts | 9 + src/renderer/utils/createStorage.ts | 30 +-- 82 files changed, 1767 insertions(+), 1196 deletions(-) create mode 100644 src/extensions/core-api/main/catalog.ts rename src/{renderer/api/catalog-category-registry.ts => extensions/core-api/main/index.ts} (94%) create mode 100644 src/extensions/core-api/renderer/catalog.ts rename src/{renderer/api/catalog-entity.ts => extensions/core-api/renderer/index.ts} (62%) create mode 100644 src/main/catalog/catalog-category-registry.ts create mode 100644 src/main/catalog/catalog-entity.ts create mode 100644 src/main/initializers/catalog-categories.ts create mode 100644 src/main/initializers/index.ts create mode 100644 src/renderer/catalog-entities/index.ts create mode 100644 src/renderer/catalog-entities/kubernetes-cluster.ts rename src/{extensions/core-api/catalog.ts => renderer/catalog-entities/web-link.ts} (67%) rename src/renderer/{api => catalog}/__tests__/catalog-entity-registry.test.ts (83%) create mode 100644 src/renderer/catalog/catalog-categories.ts create mode 100644 src/renderer/catalog/catalog-category-registry.ts rename src/renderer/{api => catalog}/catalog-entity-registry.ts (69%) create mode 100644 src/renderer/catalog/catalog-entity.ts create mode 100644 src/renderer/catalog/index.ts create mode 100644 src/renderer/initializers/catalog-categories.ts rename src/{common/catalog-entities/icons => renderer/initializers/catalog-icons}/kubernetes.svg (100%) create mode 100644 src/renderer/initializers/index.ts rename src/{common => renderer}/k8s/resource-stack.ts (93%) diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 7a82547e61..7f45144cac 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -23,7 +23,7 @@ import fs from "fs"; import mockFs from "mock-fs"; import yaml from "js-yaml"; import { Cluster } from "../../main/cluster"; -import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; +import { ClusterPreferencesStore, getClusterIdFromHost } from "../cluster-store"; import { Console } from "console"; import { stdout, stderr } from "process"; @@ -74,8 +74,8 @@ jest.mock("electron", () => { describe("empty config", () => { beforeEach(async () => { - ClusterStore.getInstance(false)?.unregisterIpcListener(); - ClusterStore.resetInstance(); + ClusterPreferencesStore.getInstance(false)?.unregisterIpcListener(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({}) @@ -84,7 +84,7 @@ describe("empty config", () => { mockFs(mockOpts); - await ClusterStore.createInstance().load(); + await ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -93,7 +93,7 @@ describe("empty config", () => { describe("with foo cluster added", () => { beforeEach(() => { - ClusterStore.getInstance().addCluster( + ClusterPreferencesStore.getInstance().addCluster( new Cluster({ id: "foo", contextName: "foo", @@ -102,13 +102,13 @@ describe("empty config", () => { icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig) + kubeConfigPath: ClusterPreferencesStore.embedCustomKubeConfig("foo", kubeconfig) }) ); }); it("adds new cluster to store", async () => { - const storedCluster = ClusterStore.getInstance().getById("foo"); + const storedCluster = ClusterPreferencesStore.getInstance().getById("foo"); expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); @@ -116,26 +116,26 @@ describe("empty config", () => { }); it("removes cluster from store", async () => { - await ClusterStore.getInstance().removeById("foo"); - expect(ClusterStore.getInstance().getById("foo")).toBeNull(); + await ClusterPreferencesStore.getInstance().removeById("foo"); + expect(ClusterPreferencesStore.getInstance().getById("foo")).toBeNull(); }); it("sets active cluster", () => { - ClusterStore.getInstance().setActive("foo"); - expect(ClusterStore.getInstance().active.id).toBe("foo"); + ClusterPreferencesStore.getInstance().setActive("foo"); + expect(ClusterPreferencesStore.getInstance().active.id).toBe("foo"); }); }); describe("with prod and dev clusters added", () => { beforeEach(() => { - ClusterStore.getInstance().addClusters( + ClusterPreferencesStore.getInstance().addClusters( new Cluster({ id: "prod", contextName: "foo", preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig) + kubeConfigPath: ClusterPreferencesStore.embedCustomKubeConfig("prod", kubeconfig) }), new Cluster({ id: "dev", @@ -143,18 +143,18 @@ describe("empty config", () => { preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig) + kubeConfigPath: ClusterPreferencesStore.embedCustomKubeConfig("dev", kubeconfig) }) ); }); it("check if store can contain multiple clusters", () => { - expect(ClusterStore.getInstance().hasClusters()).toBeTruthy(); - expect(ClusterStore.getInstance().clusters.size).toBe(2); + expect(ClusterPreferencesStore.getInstance().hasClusters()).toBeTruthy(); + expect(ClusterPreferencesStore.getInstance().clusters.size).toBe(2); }); it("check if cluster's kubeconfig file saved", () => { - const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); + const file = ClusterPreferencesStore.embedCustomKubeConfig("boo", "kubeconfig"); expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); }); @@ -163,7 +163,7 @@ describe("empty config", () => { describe("config with existing clusters", () => { beforeEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -201,7 +201,7 @@ describe("config with existing clusters", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -209,24 +209,24 @@ describe("config with existing clusters", () => { }); it("allows to retrieve a cluster", () => { - const storedCluster = ClusterStore.getInstance().getById("cluster1"); + const storedCluster = ClusterPreferencesStore.getInstance().getById("cluster1"); expect(storedCluster.id).toBe("cluster1"); expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); it("allows to delete a cluster", () => { - ClusterStore.getInstance().removeById("cluster2"); - const storedCluster = ClusterStore.getInstance().getById("cluster1"); + ClusterPreferencesStore.getInstance().removeById("cluster2"); + const storedCluster = ClusterPreferencesStore.getInstance().getById("cluster1"); expect(storedCluster).toBeTruthy(); - const storedCluster2 = ClusterStore.getInstance().getById("cluster2"); + const storedCluster2 = ClusterPreferencesStore.getInstance().getById("cluster2"); expect(storedCluster2).toBeNull(); }); it("allows getting all of the clusters", async () => { - const storedClusters = ClusterStore.getInstance().clustersList; + const storedClusters = ClusterPreferencesStore.getInstance().clustersList; expect(storedClusters.length).toBe(3); expect(storedClusters[0].id).toBe("cluster1"); @@ -259,7 +259,7 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -291,7 +291,7 @@ users: mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -299,7 +299,7 @@ users: }); it("does not enable clusters with invalid kubeconfig", () => { - const storedClusters = ClusterStore.getInstance().clustersList; + const storedClusters = ClusterPreferencesStore.getInstance().clustersList; expect(storedClusters.length).toBe(1); }); @@ -334,7 +334,7 @@ const minimalValidKubeConfig = JSON.stringify({ describe("pre 2.0 config with an existing cluster", () => { beforeEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -350,7 +350,7 @@ describe("pre 2.0 config with an existing cluster", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -358,7 +358,7 @@ describe("pre 2.0 config with an existing cluster", () => { }); it("migrates to modern format with kubeconfig in a file", async () => { - const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath; + const config = ClusterPreferencesStore.getInstance().clustersList[0].kubeConfigPath; expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`); }); @@ -366,7 +366,7 @@ describe("pre 2.0 config with an existing cluster", () => { describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => { beforeEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -420,7 +420,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -428,7 +428,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => }); it("replaces array format access token and expiry into string", async () => { - const file = ClusterStore.getInstance().clustersList[0].kubeConfigPath; + const file = ClusterPreferencesStore.getInstance().clustersList[0].kubeConfigPath; const config = fs.readFileSync(file, "utf8"); const kc = yaml.safeLoad(config); @@ -439,7 +439,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => describe("pre 2.6.0 config with a cluster icon", () => { beforeEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -462,7 +462,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -470,7 +470,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { }); it("moves the icon into preferences", async () => { - const storedClusterData = ClusterStore.getInstance().clustersList[0]; + const storedClusterData = ClusterPreferencesStore.getInstance().clustersList[0]; expect(storedClusterData.hasOwnProperty("icon")).toBe(false); expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true); @@ -480,7 +480,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { describe("for a pre 2.7.0-beta.0 config without a workspace", () => { beforeEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -501,7 +501,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -511,7 +511,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { describe("pre 3.6.0-beta.1 config with an existing cluster", () => { beforeEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); const mockOpts = { "tmp": { "lens-cluster-store.json": JSON.stringify({ @@ -537,7 +537,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterPreferencesStore.createInstance().load(); }); afterEach(() => { @@ -545,13 +545,13 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { }); it("migrates to modern format with kubeconfig in a file", async () => { - const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath; + const config = ClusterPreferencesStore.getInstance().clustersList[0].kubeConfigPath; expect(fs.readFileSync(config, "utf8")).toBe(minimalValidKubeConfig); }); it("migrates to modern format with icon not in file", async () => { - const { icon } = ClusterStore.getInstance().clustersList[0].preferences; + const { icon } = ClusterPreferencesStore.getInstance().clustersList[0].preferences; expect(icon.startsWith("data:;base64,")).toBe(true); }); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 51002fee23..656128d74f 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -20,7 +20,7 @@ */ import mockFs from "mock-fs"; -import { ClusterStore } from "../cluster-store"; +import { ClusterPreferencesStore } from "../cluster-store"; import { HotbarStore } from "../hotbar-store"; jest.mock("../../renderer/api/catalog-entity-registry", () => ({ @@ -45,7 +45,7 @@ jest.mock("../../renderer/api/catalog-entity-registry", () => ({ })); const testCluster = { - uid: "test", + id: "test", name: "test", apiVersion: "v1", kind: "Cluster", @@ -53,11 +53,6 @@ const testCluster = { phase: "Running" }, spec: {}, - getName: jest.fn(), - getId: jest.fn(), - onDetailsOpen: jest.fn(), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), metadata: { uid: "test", name: "test", @@ -66,7 +61,7 @@ const testCluster = { }; const minikubeCluster = { - uid: "minikube", + id: "minikube", name: "minikube", apiVersion: "v1", kind: "Cluster", @@ -74,11 +69,6 @@ const minikubeCluster = { phase: "Running" }, spec: {}, - getName: jest.fn(), - getId: jest.fn(), - onDetailsOpen: jest.fn(), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), metadata: { uid: "minikube", name: "minikube", @@ -87,7 +77,7 @@ const minikubeCluster = { }; const awsCluster = { - uid: "aws", + id: "aws", name: "aws", apiVersion: "v1", kind: "Cluster", @@ -95,11 +85,6 @@ const awsCluster = { phase: "Running" }, spec: {}, - getName: jest.fn(), - getId: jest.fn(), - onDetailsOpen: jest.fn(), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), metadata: { uid: "aws", name: "aws", @@ -120,8 +105,8 @@ jest.mock("electron", () => { describe("HotbarStore", () => { beforeEach(() => { - ClusterStore.resetInstance(); - ClusterStore.createInstance(); + ClusterPreferencesStore.resetInstance(); + ClusterPreferencesStore.createInstance(); HotbarStore.resetInstance(); mockFs({ tmp: { "lens-hotbar-store.json": "{}" } }); diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 43fee50eaf..454c978dff 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -19,15 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; -import { CatalogEntity, CatalogEntityActionContext, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; -import { ClusterStore } from "../cluster-store"; -import { requestMain } from "../ipc"; -import { productName } from "../vars"; -import { CatalogCategory, CatalogCategorySpec } from "../catalog"; -import { app } from "electron"; - +import type { CatalogEntityStatus } from "../catalog"; +import type { CatalogEntitySpec } from "../catalog/catalog-entity"; export type KubernetesClusterPrometheusMetrics = { address?: { @@ -39,143 +32,15 @@ export type KubernetesClusterPrometheusMetrics = { type?: string; }; -export type KubernetesClusterSpec = { +export interface KubernetesClusterSpec extends CatalogEntitySpec { kubeconfigPath: string; kubeconfigContext: string; metrics?: { source: string; prometheus?: KubernetesClusterPrometheusMetrics; } -}; +} export interface KubernetesClusterStatus extends CatalogEntityStatus { phase: "connected" | "disconnected"; } - -export class KubernetesCluster extends CatalogEntity { - public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public readonly kind = "KubernetesCluster"; - - async connect(): Promise { - if (app) { - const cluster = ClusterStore.getInstance().getById(this.metadata.uid); - - if (!cluster) return; - - await cluster.activate(); - - return; - } - - await requestMain(clusterActivateHandler, this.metadata.uid, false); - - return; - } - - async disconnect(): Promise { - if (app) { - const cluster = ClusterStore.getInstance().getById(this.metadata.uid); - - if (!cluster) return; - - cluster.disconnect(); - - return; - } - - await requestMain(clusterDisconnectHandler, this.metadata.uid, false); - - return; - } - - async onRun(context: CatalogEntityActionContext) { - context.navigate(`/cluster/${this.metadata.uid}`); - } - - onDetailsOpen(): void { - // - } - - onSettingsOpen(): void { - // - } - - async onContextMenuOpen(context: CatalogEntityContextMenuContext) { - context.menuItems = [ - { - title: "Settings", - onlyVisibleForSource: "local", - onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`) - }, - ]; - - if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { - context.menuItems.push({ - title: "Delete", - onlyVisibleForSource: "local", - onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), - confirm: { - message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?` - } - }); - } - - if (this.status.phase == "connected") { - context.menuItems.push({ - title: "Disconnect", - onClick: async () => { - ClusterStore.getInstance().deactivate(this.metadata.uid); - requestMain(clusterDisconnectHandler, this.metadata.uid); - } - }); - } else { - context.menuItems.push({ - title: "Connect", - onClick: async () => { - context.navigate(`/cluster/${this.metadata.uid}`); - } - }); - } - - const category = catalogCategoryRegistry.getCategoryForEntity(this); - - if (category) category.emit("contextMenuOpen", this, context); - } -} - -export class KubernetesClusterCategory extends CatalogCategory { - public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; - public readonly kind = "CatalogCategory"; - public metadata = { - name: "Kubernetes Clusters", - icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line - }; - public spec: CatalogCategorySpec = { - group: "entity.k8slens.dev", - versions: [ - { - name: "v1alpha1", - entityClass: KubernetesCluster - } - ], - names: { - kind: "KubernetesCluster" - } - }; - - constructor() { - super(); - - this.on("onCatalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { - ctx.menuItems.push({ - icon: "text_snippet", - title: "Add from kubeconfig", - onClick: () => { - ctx.navigate("/add-cluster"); - } - }); - }); - } -} - -catalogCategoryRegistry.add(new KubernetesClusterCategory()); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 7e0f024421..fe2e29b3f2 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -19,57 +19,12 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; +import type { CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; export interface WebLinkStatus extends CatalogEntityStatus { phase: "valid" | "invalid"; } -export type WebLinkSpec = { +export interface WebLinkSpec extends CatalogEntitySpec { url: string; -}; - -export class WebLink extends CatalogEntity { - public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public readonly kind = "WebLink"; - - async onRun() { - window.open(this.spec.url, "_blank"); - } - - public onSettingsOpen(): void { - return; - } - - public onDetailsOpen(): void { - return; - } - - public onContextMenuOpen(): void { - return; - } } - -export class WebLinkCategory extends CatalogCategory { - public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; - public readonly kind = "CatalogCategory"; - public metadata = { - name: "Web Links", - icon: "link" - }; - public spec = { - group: "entity.k8slens.dev", - versions: [ - { - name: "v1alpha1", - entityClass: WebLink - } - ], - names: { - kind: "WebLink" - } - }; -} - -catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/catalog/catalog-category-registry.ts b/src/common/catalog/catalog-category-registry.ts index ee61429859..3df39414ad 100644 --- a/src/common/catalog/catalog-category-registry.ts +++ b/src/common/catalog/catalog-category-registry.ts @@ -19,67 +19,103 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { action, computed, observable, makeObservable } from "mobx"; -import { Disposer, ExtendedMap } from "../utils"; -import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import { action, computed } from "mobx"; +import type { CatalogEntity } from "../../main/catalog"; +import { Disposer, disposer, ExtendedObservableMap, iter, Singleton } from "../utils"; +import { CatalogCategoryRegistration as CommonCatalogCategoryRegistration, CatalogCategorySpecVersion, CategoryMetadata, parseApiVersion, WithId } from "./catalog-entity"; +import util from "util"; +import { once } from "lodash"; -export class CatalogCategoryRegistry { - protected categories = observable.set(); - protected groupKinds = new ExtendedMap>(); +const validApiVersions = new Map>( + [ + ["catalog.k8slens.dev", new Set("v1alpha1")] + ], +); - constructor() { - makeObservable(this); - } +function getValidityList(items: Iterable): string { + let res = ""; - @action add(category: CatalogCategory): Disposer { - this.categories.add(category); - this.updateGroupKinds(category); - - return () => { - this.categories.delete(category); - this.groupKinds.clear(); - }; - } - - private updateGroupKinds(category: CatalogCategory) { - this.groupKinds - .getOrInsert(category.spec.group, ExtendedMap.new) - .strictSet(category.spec.names.kind, category); - } - - @computed get items() { - return Array.from(this.categories); - } - - getForGroupKind(group: string, kind: string): T | undefined { - return this.groupKinds.get(group)?.get(kind) as T; - } - - getEntityForData(data: CatalogEntityData & CatalogEntityKindData) { - const category = this.getCategoryForEntity(data); - - if (!category) { - return null; + for (const item of items) { + if (res.length) { + res += ", "; } - const splitApiVersion = data.apiVersion.split("/"); - const version = splitApiVersion[1]; - - const specVersion = category.spec.versions.find((v) => v.name === version); - - if (!specVersion) { - return null; - } - - return new specVersion.entityClass(data); + res += item; } - getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData): T | undefined { - const splitApiVersion = data.apiVersion.split("/"); - const group = splitApiVersion[0]; + return res; +} - return this.getForGroupKind(group, data.kind); +const validGroupList = getValidityList(validApiVersions.keys()); + +function validateCatalogCategoryRegistration>(reg: CatalogCategoryRegistration): void { + const { group, version } = parseApiVersion(reg.apiVersion); + const validVersions = validApiVersions.get(group); + const fGroup = util.inspect(group, false, null, false); + const fVersion = util.inspect(version, false, null, false); + + if (!validVersions) { + throw new TypeError(`Invalid group: ${fGroup}. Valid groups are: ${validGroupList}`); + } + + if (!validVersions.has(version)) { + throw new TypeError(`Unsupported version: ${fVersion} for ${fGroup}. Valid versions are: ${getValidityList(validVersions)}`); } } -export const catalogCategoryRegistry = new CatalogCategoryRegistry(); +export abstract class CatalogCategoryRegistry< + Registration extends CommonCatalogCategoryRegistration, + Registered extends Registration, +> extends Singleton { + /** + * This is a mapping based on the versions of Categories, see `./catalog-entity` for the validation + */ + protected groupVersionKinds = new ExtendedObservableMap>>(); + + protected abstract register(registration: Registration): Registered; + + @action add(registration: Registration): Disposer { + validateCatalogCategoryRegistration(registration); + + return this.updateGroupKinds(this.register(registration)); + } + + private updateGroupKinds(category: Registered): Disposer { + const { group, versions, names: { kind } } = category.spec; + const groups = this.groupVersionKinds.getOrInsert(group, ExtendedObservableMap.new); + const cleanup = disposer(); + + for (const { version } of versions) { + const versioning = groups.getOrInsert(version, ExtendedObservableMap.new); + + versioning.strictSet(kind, { ...category, id: `${group}/${kind}` }); + cleanup.push(once(() => versioning.delete(kind))); + } + + return cleanup; + } + + @computed get items() { + return Array.from(iter.flatMap(this.groupVersionKinds.values(), groups => iter.flatMap(groups.values(), kinds => kinds.values()))); + } + + getForGroupKind(group: string, version: string, kind: string): Registration | undefined { + return this.groupVersionKinds.get(group)?.get(version)?.get(kind); + } + + protected getRegistered(apiVersion: string, kind: string) { + const { group, version } = parseApiVersion(apiVersion); + + return this.groupVersionKinds.get(group)?.get(version)?.get(kind); + } + + hasForGroupKind(group: string, version: string, kind: string): boolean { + return Boolean(this.getForGroupKind(group, version, kind)); + } + + getCategoryForEntity(data: CatalogEntity): Registration | undefined { + const { group, version } = parseApiVersion(data.apiVersion); + + return this.getForGroupKind(group, version, data.kind); + } +} diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index f30a392464..61d037399c 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -19,52 +19,83 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { EventEmitter } from "events"; -import { observable, makeObservable } from "mobx"; +import URLParse from "url-parse"; -type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; -type ExtractEntityStatusType = Entity extends CatalogEntity ? Status : never; -type ExtractEntitySpecType = Entity extends CatalogEntity ? Spec : never; - -export type CatalogEntityConstructor = ( - (new (data: CatalogEntityData< - ExtractEntityMetadataType, - ExtractEntityStatusType, - ExtractEntitySpecType - >) => Entity) -); - -export interface CatalogCategoryVersion { - name: string; - entityClass: CatalogEntityConstructor; +export interface ParsedApiVersion { + group: string; + version?: string; } -export interface CatalogCategorySpec { +const versionSchema = /^\/(?v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?)$/; + +/** + * Attempts to parse an ApiVersion string or a group string + * @param apiVersionOrGroup A string that should be either of the form `/` or `` for any version + * @param strict if true then will throw an error if `` is not provided + * @default strict = true + * @returns A parsed data + */ +export function parseApiVersion(apiVersionOrGroup: string, strict: false): ParsedApiVersion; +export function parseApiVersion(apiVersionOrGroup: string, strict?: true): Required; + +export function parseApiVersion(apiVersionOrGroup: string, strict?: boolean): ParsedApiVersion { + strict ??= true; + + const parsed = new URLParse(`lens://${apiVersionOrGroup}`); + + if ( + parsed.protocol !== "lens:" + || parsed.hash + || parsed.query + || parsed.auth + || parsed.port + || parsed.password + || parsed.username + ) { + throw new TypeError(`invalid apiVersion string: ${apiVersionOrGroup}`); + } + + if (!parsed.pathname) { + throw new TypeError(`missing version on apiVersion: ${apiVersionOrGroup}`); + } + + const match = parsed.pathname.match(versionSchema); + + if (versionSchema && !match && strict) { + throw new TypeError(`invalid version on apiVersion: ${apiVersionOrGroup}`); + } + + return { + group: parsed.hostname, + version: match?.groups.version, + }; +} + +export interface CatalogCategorySpecVersion { + version: string; +} + +export interface CatalogCategorySpec { group: string; - versions: CatalogCategoryVersion[]; + versions: Version[]; names: { kind: string; }; } -export abstract class CatalogCategory extends EventEmitter { - abstract readonly apiVersion: string; - abstract readonly kind: string; - abstract metadata: { - name: string; - icon: string; - }; - abstract spec: CatalogCategorySpec; +export interface CategoryMetadata { + name: string; +} - static parseId(id = ""): { group?: string, kind?: string } { - const [group, kind] = id.split("/") ?? []; +export interface CatalogCategoryRegistration { + readonly apiVersion: string; + readonly kind: string; + metadata: Metadata; + spec: CatalogCategorySpec; +} - return { group, kind }; - } - - public getId(): string { - return `${this.spec.group}/${this.spec.names.kind}`; - } +export interface WithId { + readonly id: string; } export interface CatalogEntityMetadata { @@ -83,92 +114,9 @@ export interface CatalogEntityStatus { active?: boolean; } -export interface CatalogEntityActionContext { - navigate: (url: string) => void; - setCommandPaletteContext: (context?: CatalogEntity) => void; -} - -export interface CatalogEntityContextMenu { - title: string; - onlyVisibleForSource?: string; // show only if empty or if matches with entity source - onClick: () => void | Promise; - confirm?: { - message: string; - } -} - -export interface CatalogEntityAddMenu extends CatalogEntityContextMenu { - icon: string; -} - -export interface CatalogEntitySettingsMenu { - group?: string; - title: string; - components: { - View: React.ComponentType - }; -} - -export interface CatalogEntityContextMenuContext { - navigate: (url: string) => void; - menuItems: CatalogEntityContextMenu[]; -} - -export interface CatalogEntitySettingsContext { - menuItems: CatalogEntityContextMenu[]; -} - -export interface CatalogEntityAddMenuContext { - navigate: (url: string) => void; - menuItems: CatalogEntityAddMenu[]; -} - export type CatalogEntitySpec = Record; -export interface CatalogEntityData< - Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, - Status extends CatalogEntityStatus = CatalogEntityStatus, - Spec extends CatalogEntitySpec = CatalogEntitySpec, -> { - metadata: Metadata; - status: Status; - spec: Spec; -} - export interface CatalogEntityKindData { readonly apiVersion: string; readonly kind: string; } - -export abstract class CatalogEntity< - Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, - Status extends CatalogEntityStatus = CatalogEntityStatus, - Spec extends CatalogEntitySpec = CatalogEntitySpec, -> implements CatalogEntityKindData { - public abstract readonly apiVersion: string; - public abstract readonly kind: string; - - @observable metadata: Metadata; - @observable status: Status; - @observable spec: Spec; - - constructor(data: CatalogEntityData) { - makeObservable(this); - this.metadata = data.metadata; - this.status = data.status; - this.spec = data.spec; - } - - public getId(): string { - return this.metadata.uid; - } - - public getName(): string { - return this.metadata.name; - } - - public abstract onRun?(context: CatalogEntityActionContext): void | Promise; - public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise; - public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; - public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; -} diff --git a/src/common/catalog/index.ts b/src/common/catalog/index.ts index 81d166c870..5c69b3a5bb 100644 --- a/src/common/catalog/index.ts +++ b/src/common/catalog/index.ts @@ -19,5 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export * from "./catalog-category-registry"; export * from "./catalog-entity"; +export * from "./catalog-category-registry"; diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 718151c3ed..955f897d9b 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -20,11 +20,12 @@ */ import { handleRequest } from "./ipc"; -import { ClusterId, ClusterStore } from "./cluster-store"; +import type { ClusterId } from "./cluster-store"; import { appEventBus } from "./event-bus"; import { ResourceApplier } from "../main/resource-applier"; import { ipcMain, IpcMainInvokeEvent } from "electron"; import { clusterFrameMap } from "./cluster-frames"; +import { ClusterManager } from "../main/cluster-manager"; export const clusterActivateHandler = "cluster:activate"; export const clusterSetFrameIdHandler = "cluster:set-frame-id"; @@ -35,13 +36,13 @@ export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; if (ipcMain) { handleRequest(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { - return ClusterStore.getInstance() + return ClusterManager.getInstance() .getById(clusterId) ?.activate(force); }); handleRequest(clusterSetFrameIdHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId) => { - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = ClusterManager.getInstance().getById(clusterId); if (cluster) { clusterFrameMap.set(cluster.id, { frameId: event.frameId, processId: event.processId }); @@ -50,14 +51,14 @@ if (ipcMain) { }); handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => { - return ClusterStore.getInstance() + return ClusterManager.getInstance() .getById(clusterId) ?.refresh({ refreshMetadata: true }); }); handleRequest(clusterDisconnectHandler, (event, clusterId: ClusterId) => { appEventBus.emit({name: "cluster", action: "stop"}); - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = ClusterManager.getInstance().getById(clusterId); if (cluster) { cluster.disconnect(); @@ -67,7 +68,7 @@ if (ipcMain) { handleRequest(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { appEventBus.emit({name: "cluster", action: "kubectl-apply-all"}); - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = ClusterManager.getInstance().getById(clusterId); if (cluster) { const applier = new ResourceApplier(cluster); @@ -86,7 +87,7 @@ if (ipcMain) { handleRequest(clusterKubectlDeleteAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { appEventBus.emit({name: "cluster", action: "kubectl-delete-all"}); - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = ClusterManager.getInstance().getById(clusterId); if (cluster) { const applier = new ResourceApplier(cluster); diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 31ecd18031..2df2788071 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -20,20 +20,15 @@ */ import path from "path"; -import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron"; -import { unlink } from "fs-extra"; -import { action, comparer, computed, observable, reaction, makeObservable } from "mobx"; +import { app, ipcRenderer, remote } from "electron"; +import { action, comparer, observable, toJS } from "mobx"; import { BaseStore } from "./base-store"; -import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store"; -import logger from "../main/logger"; -import { appEventBus } from "./event-bus"; import { dumpConfigYaml } from "./kube-helpers"; import { saveToAppFiles } from "./utils/saveToAppFiles"; import type { KubeConfig } from "@kubernetes/client-node"; -import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; +import { disposer } from "./utils"; import type { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting"; -import { disposer, noop, toJS } from "./utils"; export interface ClusterIconUpload { clusterId: string; @@ -52,8 +47,7 @@ export type ClusterPrometheusMetadata = { }; export interface ClusterStoreModel { - activeCluster?: ClusterId; // last opened cluster - clusters?: ClusterModel[]; + preferences?: [string, ClusterPreferences][]; } export type ClusterId = string; @@ -113,17 +107,17 @@ export interface ClusterPrometheusPreferences { }; } -export class ClusterStore extends BaseStore { +export class ClusterPreferencesStore extends BaseStore { static get storedKubeConfigFolder(): string { return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); } static getCustomKubeConfigPath(clusterId: ClusterId): string { - return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); + return path.resolve(ClusterPreferencesStore.storedKubeConfigFolder, clusterId); } static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { - const filePath = ClusterStore.getCustomKubeConfigPath(clusterId); + const filePath = ClusterPreferencesStore.getCustomKubeConfigPath(clusterId); const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); saveToAppFiles(filePath, fileContents, { mode: 0o600 }); @@ -131,11 +125,8 @@ export class ClusterStore extends BaseStore { return filePath; } - @observable activeCluster: ClusterId; - @observable removedClusters = observable.map(); - @observable clusters = observable.map(); + clusterPreferences = observable.map(); - private static stateRequestChannel = "cluster:states"; protected disposer = disposer(); constructor() { @@ -147,206 +138,32 @@ export class ClusterStore extends BaseStore { }, migrations, }); - - makeObservable(this); - - this.pushStateToViewsAutomatically(); } - async load() { - await super.load(); - type clusterStateSync = { - id: string; - state: ClusterState; - }; + getById(id: string): ClusterPreferences { + return this.clusterPreferences.get(id); + } + isMetricHidden(resource: ResourceType): boolean { if (ipcRenderer) { - logger.info("[CLUSTER-STORE] requesting initial state sync"); - const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel); + const id = getHostedClusterId(); - clusterStates.forEach((clusterState) => { - const cluster = this.getById(clusterState.id); - - if (cluster) { - cluster.setState(clusterState.state); - } - }); - } else if (ipcMain) { - handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { - const clusterStates: clusterStateSync[] = []; - - this.clustersList.forEach((cluster) => { - clusterStates.push({ - state: cluster.getState(), - id: cluster.id - }); - }); - - return clusterStates; - }); + return Boolean(this.clusterPreferences.get(id).hiddenMetrics?.includes(resource)); } - } - protected pushStateToViewsAutomatically() { - if (ipcMain) { - this.disposer.push( - reaction(() => this.connectedClustersList, () => { - this.pushState(); - }), - () => unsubscribeAllFromBroadcast("cluster:state"), - ); - } - } - - registerIpcListener() { - logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`); - subscribeToBroadcast("cluster:state", (event, clusterId: string, state: ClusterState) => { - logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, clusterId, state); - this.getById(clusterId)?.setState(state); - }); - } - - unregisterIpcListener() { - super.unregisterIpcListener(); - this.disposer(); - } - - pushState() { - this.clusters.forEach((c) => { - c.pushState(); - }); - } - - get activeClusterId() { - return this.activeCluster; - } - - @computed get clustersList(): Cluster[] { - return Array.from(this.clusters.values()); - } - - @computed get active(): Cluster | null { - return this.getById(this.activeCluster); - } - - @computed get connectedClustersList(): Cluster[] { - return this.clustersList.filter((c) => !c.disconnected); - } - - isActive(id: ClusterId) { - return this.activeCluster === id; - } - - isMetricHidden(resource: ResourceType) { - return Boolean(this.active?.preferences.hiddenMetrics?.includes(resource)); + return true; } @action - setActive(clusterId: ClusterId) { - this.activeCluster = this.clusters.has(clusterId) - ? clusterId - : null; - } - - deactivate(id: ClusterId) { - if (this.isActive(id)) { - this.setActive(null); - } - } - - hasClusters() { - return this.clusters.size > 0; - } - - getById(id: ClusterId): Cluster | null { - 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" }); - - const cluster = clusterOrModel instanceof Cluster - ? clusterOrModel - : new Cluster(clusterOrModel); - - this.clusters.set(cluster.id, cluster); - - 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); - - if (this.activeCluster === clusterId) { - this.setActive(null); - } - - // remove only custom kubeconfigs (pasted as text) - if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { - await unlink(cluster.kubeConfigPath).catch(noop); - } - } - } - - @action - protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) { - const currentClusters = new Map(this.clusters); - const newClusters = new Map(); - const removedClusters = new Map(); - - // update new clusters - for (const clusterModel of clusters) { - try { - let cluster = currentClusters.get(clusterModel.id); - - if (cluster) { - cluster.updateModel(clusterModel); - } else { - cluster = new Cluster(clusterModel); - } - newClusters.set(clusterModel.id, cluster); - } catch { - // ignore - } - } - - // update removed clusters - currentClusters.forEach(cluster => { - if (!newClusters.has(cluster.id)) { - removedClusters.set(cluster.id, cluster); - } - }); - - this.setActive(activeCluster); - this.clusters.replace(newClusters); - this.removedClusters.replace(removedClusters); + protected fromStore({ preferences = [] }: ClusterStoreModel = {}) { + this.clusterPreferences.replace(preferences); } toJSON(): ClusterStoreModel { return toJS({ - activeCluster: this.activeCluster, - clusters: this.clustersList.map(cluster => cluster.toJSON()), + preferences: Array.from(this.clusterPreferences.entries()), + }, { + recurseEverything: true }); } } @@ -366,6 +183,6 @@ export function getHostedClusterId() { return getClusterIdFromHost(location.host); } -export function getHostedCluster(): Cluster { - return ClusterStore.getInstance().getById(getHostedClusterId()); +export function getHostedCluster() { + return ClusterPreferencesStore.getInstance().getById(getHostedClusterId()); } diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 2d91ec0cc5..4a3bd36dc0 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -25,7 +25,7 @@ import migrations from "../migrations/hotbar-store"; import * as uuid from "uuid"; import isNull from "lodash/isNull"; import { toJS } from "./utils"; -import { CatalogEntity } from "./catalog"; +import { CatalogEntity } from "../renderer/catalog"; export interface HotbarItem { entity: { diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts index 626883795d..a5ef89930c 100644 --- a/src/common/utils/disposer.ts +++ b/src/common/utils/disposer.ts @@ -23,6 +23,7 @@ export type Disposer = () => void; interface Extendable { push(...vals: T[]): void; + isEmpty: boolean; } export type ExtendableDisposer = Disposer & Extendable; @@ -37,5 +38,10 @@ export function disposer(...args: Disposer[]): ExtendableDisposer { args.push(...vals); }; - return res; + Object.defineProperty(res, "isEmpty", { + writable: false, + get: () => args.length === 0, + }); + + return res as ExtendableDisposer; } diff --git a/src/common/utils/extended-map.ts b/src/common/utils/extended-map.ts index c759fa3459..94f023c85e 100644 --- a/src/common/utils/extended-map.ts +++ b/src/common/utils/extended-map.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { action, ObservableMap } from "mobx"; +import { action, IEnhancer, IObservableMapInitialValues, ObservableMap } from "mobx"; export class ExtendedMap extends Map { static new(entries?: readonly (readonly [K, V])[] | null): ExtendedMap { @@ -67,6 +67,10 @@ export class ExtendedMap extends Map { } export class ExtendedObservableMap extends ObservableMap { + static new(initialData?: IObservableMapInitialValues, enhancer?: IEnhancer, name?: string): ExtendedObservableMap { + return new ExtendedObservableMap(initialData, enhancer, name); + } + @action getOrInsert(key: K, getVal: () => V): V { if (this.has(key)) { @@ -75,4 +79,17 @@ export class ExtendedObservableMap extends ObservableMap { return this.set(key, getVal()).get(key); } + + /** + * Set the value associated with `key` iff there was not a previous value + * @throws if `key` already in map + * @returns `this` so that `strictSet` can be chained + */ + strictSet(key: K, val: V): this { + if (this.has(key)) { + throw new TypeError("Duplicate key in map"); + } + + return this.set(key, val); + } } diff --git a/src/extensions/core-api/index.ts b/src/extensions/core-api/index.ts index 56e4076ddb..2db413ffb3 100644 --- a/src/extensions/core-api/index.ts +++ b/src/extensions/core-api/index.ts @@ -29,12 +29,16 @@ import * as EventBus from "./event-bus"; import * as Store from "./stores"; import * as Util from "./utils"; import * as Interface from "../interfaces"; -import * as Catalog from "./catalog"; +import * as Main from "./main"; +import * as Renderer from "./renderer"; +import * as Catalog from "../../common/catalog"; import * as Types from "./types"; export { App, EventBus, + Main, + Renderer, Catalog, Interface, Store, diff --git a/src/extensions/core-api/main/catalog.ts b/src/extensions/core-api/main/catalog.ts new file mode 100644 index 0000000000..9b2e609a2b --- /dev/null +++ b/src/extensions/core-api/main/catalog.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + + +import { CatalogEntityRegistry as InternalCatalogEntityRegistry, CatalogCategoryRegistry as InternalCatalogCategoryRegistry, CatalogEntity, CatalogCategoryRegistration, SpecEnhancer } from "../../../main/catalog"; + +export type { + CatalogEntity, +} from "../../../main/catalog"; + +export class CatalogEntityRegistry { + static get items() { + return InternalCatalogEntityRegistry.getInstance().items; + } +} + +export class CatalogCategoryRegistry { + static add(category: CatalogCategoryRegistration) { + return InternalCatalogCategoryRegistry.getInstance().add(category); + } + + static get items() { + return InternalCatalogCategoryRegistry.getInstance().items; + } + + static getForGroupKind(group: string, version: string, kind: string) { + return InternalCatalogCategoryRegistry.getInstance().getForGroupKind(group, version, kind); + } + + static hasForGroupKind(group: string, version: string, kind: string) { + return InternalCatalogCategoryRegistry.getInstance().hasForGroupKind(group, version, kind); + } + + static getCategoryForEntity(data: CatalogEntity) { + return InternalCatalogCategoryRegistry.getInstance().getCategoryForEntity(data); + } + + static registerSpecEnhancer(apiVersion: string, kind: string, handler: SpecEnhancer) { + return InternalCatalogCategoryRegistry.getInstance().registerSpecEnhancer(apiVersion, kind, handler); + } +} diff --git a/src/renderer/api/catalog-category-registry.ts b/src/extensions/core-api/main/index.ts similarity index 94% rename from src/renderer/api/catalog-category-registry.ts rename to src/extensions/core-api/main/index.ts index a345de5250..bfd02582d6 100644 --- a/src/renderer/api/catalog-category-registry.ts +++ b/src/extensions/core-api/main/index.ts @@ -19,4 +19,4 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { catalogCategoryRegistry } from "../../common/catalog"; +export * as Catalog from "./catalog"; diff --git a/src/extensions/core-api/renderer/catalog.ts b/src/extensions/core-api/renderer/catalog.ts new file mode 100644 index 0000000000..5ab2b503f0 --- /dev/null +++ b/src/extensions/core-api/renderer/catalog.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + + +import { CatalogEntityRegistry as InternalCatalogEntityRegistry, CatalogCategoryRegistry as InternalCatalogCategoryRegistry, CatalogEntity, CategoryHandlerNames, CatalogHandler, EntityContextHandlers, CategoryHandlers, GlobalContextHandlers } from "../../../renderer/catalog"; +import type { CatalogCategoryRegistration } from "../../../renderer/catalog"; + +export type { + CatalogEntity, +} from "../../../renderer/catalog"; + +export class CatalogEntityRegistry { + static getItemsForApiKind(apiVersion: string, kind: string): T[] { + return InternalCatalogEntityRegistry.getInstance().getItemsForApiKind(apiVersion, kind); + } +} + +export class CatalogCategoryRegistry { + static add(category: CatalogCategoryRegistration) { + return InternalCatalogCategoryRegistry.getInstance().add(category); + } + + static get items() { + return InternalCatalogCategoryRegistry.getInstance().items; + } + + static getForGroupKind(group: string, version: string, kind: string) { + return InternalCatalogCategoryRegistry.getInstance().getForGroupKind(group, version, kind); + } + + static hasForGroupKind(group: string, version: string, kind: string) { + return InternalCatalogCategoryRegistry.getInstance().hasForGroupKind(group, version, kind); + } + + static getCategoryForEntity(data: CatalogEntity) { + return InternalCatalogCategoryRegistry.getInstance().getCategoryForEntity(data); + } + + static registerHandler(apiVersion: string, kind: string, handlerName: CategoryHandlerNames, handler: CatalogHandler) { + return InternalCatalogCategoryRegistry.getInstance().registerHandler(apiVersion, kind, handlerName, handler); + } + + static runEntityHandlersFor(entity: CatalogEntity, handlerName: "onContextMenuOpen"): ReturnType; + static runEntityHandlersFor(entity: CatalogEntity, handlerName: "onSettingsOpen"): ReturnType; + static runEntityHandlersFor(entity: CatalogEntity, handlerName: EntityContextHandlers): ReturnType { + return InternalCatalogCategoryRegistry.getInstance().runEntityHandlersFor(entity, handlerName as any); + } + + static runGlobalHandlersFor(reg: CatalogCategoryRegistration, handlerName: "onCatalogAddMenu"): ReturnType; + static runGlobalHandlersFor(reg: CatalogCategoryRegistration, handlerName: GlobalContextHandlers): ReturnType { + return InternalCatalogCategoryRegistry.getInstance().runGlobalHandlersFor(reg, handlerName as any); + } +} diff --git a/src/renderer/api/catalog-entity.ts b/src/extensions/core-api/renderer/index.ts similarity index 62% rename from src/renderer/api/catalog-entity.ts rename to src/extensions/core-api/renderer/index.ts index 63a5ce62eb..bfd02582d6 100644 --- a/src/renderer/api/catalog-entity.ts +++ b/src/extensions/core-api/renderer/index.ts @@ -19,24 +19,4 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { navigate } from "../navigation"; -import { commandRegistry } from "../../extensions/registries"; -import type { CatalogEntity } from "../../common/catalog"; - -export { CatalogCategory, CatalogEntity } from "../../common/catalog"; -export type { - CatalogEntityData, - CatalogEntityKindData, - CatalogEntityActionContext, - CatalogEntityAddMenuContext, - CatalogEntityAddMenu, - CatalogEntityContextMenu, - CatalogEntityContextMenuContext, -} from "../../common/catalog"; - -export const catalogEntityRunContext = { - navigate: (url: string) => navigate(url), - setCommandPaletteContext: (entity?: CatalogEntity) => { - commandRegistry.activeEntity = entity; - } -}; +export * as Catalog from "./catalog"; diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index 023a8b92f9..26dd481538 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -19,12 +19,12 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { LensExtension } from "./lens-extension"; +import { Disposers, LensExtension } from "./lens-extension"; import { WindowManager } from "../main/window-manager"; import { getExtensionPageUrl } from "./registries/page-registry"; -import { catalogEntityRegistry } from "../main/catalog"; -import type { CatalogEntity } from "../common/catalog"; -import type { IObservableArray } from "mobx"; +import { CatalogEntityRegistry } from "../main/catalog"; +import type { CatalogEntity } from "../main/catalog"; +import type { IComputedValue, IObservableArray } from "mobx"; import type { MenuRegistration } from "./registries"; export class LensMainExtension extends LensExtension { @@ -41,11 +41,11 @@ export class LensMainExtension extends LensExtension { await windowManager.navigate(pageUrl, frameId); } - addCatalogSource(id: string, source: IObservableArray) { - catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source); + addObservableCatalogSource(id: string, source: IObservableArray) { + this[Disposers].push(CatalogEntityRegistry.getInstance().addObservableSource(`${this.name}:${id}`, source)); } - removeCatalogSource(id: string) { - catalogEntityRegistry.removeSource(`${this.name}:${id}`); + addComputedCatalogSource(id: string, source: IComputedValue) { + this[Disposers].push(CatalogEntityRegistry.getInstance().addComputedSource(`${this.name}:${id}`, source)); } } diff --git a/src/extensions/registries/command-registry.ts b/src/extensions/registries/command-registry.ts index b7b822d473..06974f83a6 100644 --- a/src/extensions/registries/command-registry.ts +++ b/src/extensions/registries/command-registry.ts @@ -24,7 +24,7 @@ import { BaseRegistry } from "./base-registry"; import { makeObservable, observable } from "mobx"; import type { LensExtension } from "../lens-extension"; -import type { CatalogEntity } from "../../common/catalog"; +import type { CatalogEntity } from "../../renderer/catalog"; export type CommandContext = { entity?: CatalogEntity; diff --git a/src/extensions/registries/entity-setting-registry.ts b/src/extensions/registries/entity-setting-registry.ts index 80b20196c3..6394b1594f 100644 --- a/src/extensions/registries/entity-setting-registry.ts +++ b/src/extensions/registries/entity-setting-registry.ts @@ -20,7 +20,7 @@ */ import type React from "react"; -import type { CatalogEntity } from "../../common/catalog"; +import type { CatalogEntity } from "../../renderer/catalog"; import { BaseRegistry } from "./base-registry"; export interface EntitySettingViewProps { diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index d16dbe83f5..a0d5bf04b1 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -20,7 +20,7 @@ */ export { isAllowedResource } from "../../common/rbac"; -export { ResourceStack } from "../../common/k8s/resource-stack"; +export { ResourceStack } from "../../renderer/k8s/resource-stack"; export { apiManager } from "../../renderer/api/api-manager"; export { KubeObjectStore } from "../../renderer/kube-object.store"; export { KubeApi, forCluster } from "../../renderer/api/kube-api"; diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index e97dc07038..661ac2a577 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -21,12 +21,11 @@ import { reaction } from "mobx"; import { broadcastMessage } from "../common/ipc"; -import type { CatalogEntityRegistry } from "./catalog"; -import "../common/catalog-entities/kubernetes-cluster"; import { toJS } from "../common/utils"; +import { CatalogEntityRegistry } from "./catalog/catalog-entity-registry"; -export function pushCatalogToRenderer(catalog: CatalogEntityRegistry) { - return reaction(() => toJS(catalog.items), (items) => { +export function pushCatalogToRenderer() { + return reaction(() => toJS(CatalogEntityRegistry.getInstance().items), (items) => { broadcastMessage("catalog:items", items); }, { fireImmediately: true, diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 7fcd3c1db6..72d36de994 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -20,13 +20,13 @@ */ import { ObservableMap } from "mobx"; -import type { CatalogEntity } from "../../../common/catalog"; import { loadFromOptions } from "../../../common/kube-helpers"; import type { Cluster } from "../../cluster"; import { computeDiff, configToModels } from "../kubeconfig-sync"; import mockFs from "mock-fs"; import fs from "fs"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; +import type { CatalogEntity } from "../../catalog"; jest.mock("electron", () => ({ app: { @@ -37,7 +37,7 @@ jest.mock("electron", () => ({ describe("kubeconfig-sync.source tests", () => { beforeEach(() => { mockFs(); - ClusterStore.createInstance(); + ClusterPreferencesStore.createInstance(); }); afterEach(() => { @@ -85,7 +85,7 @@ describe("kubeconfig-sync.source tests", () => { describe("computeDiff", () => { it("should leave an empty source empty if there are no entries", () => { const contents = ""; - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; computeDiff(contents, rootSource, filePath); @@ -120,7 +120,7 @@ describe("kubeconfig-sync.source tests", () => { }], currentContext: "foobar" }); - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; fs.writeFileSync(filePath, contents); @@ -163,7 +163,7 @@ describe("kubeconfig-sync.source tests", () => { }], currentContext: "foobar" }); - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; fs.writeFileSync(filePath, contents); @@ -217,7 +217,7 @@ describe("kubeconfig-sync.source tests", () => { }], currentContext: "foobar" }); - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; fs.writeFileSync(filePath, contents); diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index c4650644da..7d7146610e 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -20,20 +20,19 @@ */ import { action, observable, IComputedValue, computed, ObservableMap, runInAction, makeObservable, observe } from "mobx"; -import type { CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry } from "../../main/catalog"; +import type { CatalogEntity } from "../../main/catalog"; +import { CatalogEntityRegistry } from "../../main/catalog"; import { watch } from "chokidar"; import fs from "fs"; import fse from "fs-extra"; import type stream from "stream"; -import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; +import { disposer, Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; import logger from "../logger"; import type { KubeConfig } from "@kubernetes/client-node"; import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers"; -import { Cluster } from "../cluster"; import { catalogEntityFromCluster } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; -import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; +import { ClusterPreferencesStore, UpdateClusterModel } from "../../common/cluster-store"; import { createHash } from "crypto"; import { homedir } from "os"; @@ -41,63 +40,67 @@ const logPrefix = "[KUBECONFIG-SYNC]:"; export class KubeconfigSyncManager extends Singleton { protected sources = observable.map, Disposer]>(); - protected syncing = false; - protected syncListDisposer?: Disposer; + protected disposers = disposer(); protected static readonly syncName = "lens:kube-sync"; constructor() { super(); - makeObservable(this); } + protected computedSource = computed(() => ( + Array.from(iter.flatMap( + this.sources.values(), + ([entities]) => entities.get() + )) + )); + + get syncing(): boolean { + return !this.disposers.isEmpty; + } + @action startSync(): void { if (this.syncing) { return; } - this.syncing = true; - logger.info(`${logPrefix} starting requested syncs`); - catalogEntityRegistry.addComputedSource(KubeconfigSyncManager.syncName, computed(() => ( - Array.from(iter.flatMap( - this.sources.values(), - ([entities]) => entities.get() - )) - ))); + this.disposers.push( + CatalogEntityRegistry.getInstance() + .addComputedSource(KubeconfigSyncManager.syncName, this.computedSource) + ); // This must be done so that c&p-ed clusters are visible - this.startNewSync(ClusterStore.storedKubeConfigFolder); + this.startNewSync(ClusterPreferencesStore.storedKubeConfigFolder); for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) { this.startNewSync(filePath); } - this.syncListDisposer = observe(UserStore.getInstance().syncKubeconfigEntries, change => { - switch (change.type) { - case "add": - this.startNewSync(change.name); - break; - case "delete": - this.stopOldSync(change.name); - break; - } - }); + this.disposers.push( + observe(UserStore.getInstance().syncKubeconfigEntries, change => { + switch (change.type) { + case "add": + this.startNewSync(change.name); + break; + case "delete": + this.stopOldSync(change.name); + break; + } + }, true) + ); } @action stopSync() { - this.syncListDisposer?.(); + this.disposers(); for (const filePath of this.sources.keys()) { this.stopOldSync(filePath); } - - catalogEntityRegistry.removeSource(KubeconfigSyncManager.syncName); - this.syncing = false; } @action @@ -149,7 +152,7 @@ export function configToModels(config: KubeConfig, filePath: string): UpdateClus return validConfigs; } -type RootSourceValue = [Cluster, CatalogEntity]; +type RootSourceValue = CatalogEntity; type RootSource = ObservableMap; // exported for testing @@ -161,12 +164,11 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); - for (const [contextName, value] of source) { + for (const contextName of source.keys()) { const model = models.get(contextName); // remove and disconnect clusters that were removed from the config if (!model) { - value[0].disconnect(); source.delete(contextName); logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); continue; @@ -177,7 +179,6 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri // diff against that // or update the model and mark it as not needed to be added - value[0].updateModel(model); models.delete(contextName); logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName }); } @@ -186,18 +187,13 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri // add new clusters to the source try { const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); - const cluster = ClusterStore.getInstance().getById(clusterId) || new Cluster({ ...model, id: clusterId}); + const entity = catalogEntityFromCluster({ + id: clusterId, + ...model + }); - if (!cluster.apiUrl) { - throw new Error("Cluster constructor failed, see above error"); - } - - const entity = catalogEntityFromCluster(cluster); - - if (!filePath.startsWith(ClusterStore.storedKubeConfigFolder)) { - entity.metadata.labels.file = filePath.replace(homedir(), "~"); - } - source.set(contextName, [cluster, entity]); + entity.metadata.labels.file = filePath.replace(homedir(), "~"); + source.set(contextName, entity); logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName }); } catch (error) { @@ -258,17 +254,17 @@ async function watchFileChanges(filePath: string): Promise<[IComputedValue>(); - const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); + const rootSource = new ExtendedObservableMap>(); + const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => from.values()))); const stoppers = new Map(); watcher .on("change", (childFilePath) => { stoppers.get(childFilePath)(); - stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, observable.map))); + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, ExtendedObservableMap.new))); }) .on("add", (childFilePath) => { - stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, observable.map))); + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, ExtendedObservableMap.new))); }) .on("unlink", (childFilePath) => { stoppers.get(childFilePath)(); diff --git a/src/main/catalog/__tests__/catalog-entity-registry.test.ts b/src/main/catalog/__tests__/catalog-entity-registry.test.ts index fff3faa3d9..dc7d620a41 100644 --- a/src/main/catalog/__tests__/catalog-entity-registry.test.ts +++ b/src/main/catalog/__tests__/catalog-entity-registry.test.ts @@ -20,34 +20,31 @@ */ import { observable, reaction } from "mobx"; -import { WebLink, WebLinkSpec, WebLinkStatus } from "../../../common/catalog-entities"; -import { catalogCategoryRegistry, CatalogEntity, CatalogEntityMetadata } from "../../../common/catalog"; +import type { CatalogEntityData } from "../../../renderer/catalog"; +import { initCatalogCategories } from "../../initializers"; +import { CatalogCategoryRegistry } from "../catalog-category-registry"; +import type { CatalogEntity } from "../catalog-entity"; import { CatalogEntityRegistry } from "../catalog-entity-registry"; -class InvalidEntity extends CatalogEntity { - public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public readonly kind = "Invalid"; +function getInvalidEntity(data: CatalogEntityData): CatalogEntity { + return { + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "Invalid", + ...data + }; +} - async onRun() { - return; - } - - public onSettingsOpen(): void { - return; - } - - public onDetailsOpen(): void { - return; - } - - public onContextMenuOpen(): void { - return; - } +function getWeblinkEntity(data: CatalogEntityData): CatalogEntity { + return { + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "WebLink", + ...data + }; } describe("CatalogEntityRegistry", () => { let registry: CatalogEntityRegistry; - const entity = new WebLink({ + const entity = getWeblinkEntity({ metadata: { uid: "test", name: "test-link", @@ -61,7 +58,7 @@ describe("CatalogEntityRegistry", () => { phase: "valid" } }); - const invalidEntity = new InvalidEntity({ + const invalidEntity = getInvalidEntity({ metadata: { uid: "invalid", name: "test-link", @@ -77,7 +74,9 @@ describe("CatalogEntityRegistry", () => { }); beforeEach(() => { - registry = new CatalogEntityRegistry(catalogCategoryRegistry); + CatalogCategoryRegistry.createInstance(); + initCatalogCategories(); + CatalogEntityRegistry.createInstance(); }); describe("addSource", () => { @@ -108,9 +107,10 @@ describe("CatalogEntityRegistry", () => { it ("removes source", () => { const source = observable.array([]); - registry.addObservableSource("test", source); + const d1 = registry.addObservableSource("test", source); + source.push(entity); - registry.removeSource("test"); + d1(); expect(registry.items.length).toEqual(0); }); diff --git a/src/main/catalog/catalog-category-registry.ts b/src/main/catalog/catalog-category-registry.ts new file mode 100644 index 0000000000..3fd3884625 --- /dev/null +++ b/src/main/catalog/catalog-category-registry.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { once } from "lodash"; +import { IComputedValue, observable, ObservableSet, when } from "mobx"; +import { CatalogCategorySpecVersion, CatalogCategoryRegistration as CommonCatalogCategoryRegistration, CategoryMetadata, CatalogEntityStatus, parseApiVersion } from "../../common/catalog"; +import { CatalogCategoryRegistry as CommonCatalogCategoryRegistry } from "../../common/catalog"; +import { disposer, Disposer } from "../../common/utils"; +import type { CatalogEntity } from "./catalog-entity"; + +type SpecFromEntity = Entity extends CatalogEntity ? Spec : never; + +export type StatusComputation = (entity: CatalogEntity) => IComputedValue; +export type SpecEnhancer = (entity: CatalogEntity) => IComputedValue>>; + +export interface CategorySpecVersion extends CatalogCategorySpecVersion { + /** + * This function is called once per ID, even if there was a period of time when that item was no longer in the catalog + */ + getStatus: StatusComputation; +} + +export type CatalogCategoryRegistration = CommonCatalogCategoryRegistration; + +export interface CatalogCategory extends CatalogCategoryRegistration { + specEnhancers: ObservableSet; +} + +export interface EntityEnhancerFunctions { + status: StatusComputation, + spec: SpecEnhancer[]; +} + +export class CatalogCategoryRegistry extends CommonCatalogCategoryRegistry { + protected register(registration: CatalogCategoryRegistration): CatalogCategory { + return { + specEnhancers: observable.set(), + ...registration + }; + } + + /** + * Adds a way compute optional part of a CatalogEntity's spec field. + * The value passed into the `handler` is the non-computed value. + * The returned value should respect the initial spec. + * @param apiVersion The apiVersion of the entity + * @param kind The kind of the entity + * @param handler A function that is called with the raw entity data, once on initial creation. + * @returns A function to remove this enhancer + */ + registerSpecEnhancer(apiVersion: string, kind: string, handler: SpecEnhancer): Disposer { + const { group, version } = parseApiVersion(apiVersion, false); + + if (version) { + // only one version to do + return disposer( + when( + () => this.hasForGroupKind(group, version, kind), + () => { + this.groupVersionKinds + .get(group) + .get(version) + .get(kind) + .specEnhancers.add(handler); + }, + ), + once(() => this.groupVersionKinds.get(group)?.get(version)?.delete(kind)), + ); + } + + throw new Error("Not providing a version for groups is not supported at this time"); + // This would requiring observing future additions to the second level of the map + // and waiting for them to add the kind + // all wrapped up in disposers + } + + getEnhancerForEntity(entity: CatalogEntity): EntityEnhancerFunctions | null { + const { group, version } = parseApiVersion(entity.apiVersion); + const catalog = this.groupVersionKinds.get(group)?.get(version)?.get(entity.kind); + + if (!catalog) { + return null; + } + + return { + status: catalog.spec.versions.find(spec => spec.version === version).getStatus, + spec: Array.from(catalog.specEnhancers) + }; + } +} diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 55162970a2..db99f919f2 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -19,40 +19,77 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity } from "../../common/catalog"; -import { iter } from "../../common/utils"; +import { computed, observable, IComputedValue, IObservableArray } from "mobx"; +import type { CatalogEntity, CatalogEntityComputed } from "./catalog-entity"; +import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; +import { CatalogCategoryRegistry } from "./catalog-category-registry"; +import type { CatalogEntitySpec, CatalogEntityStatus } from "../../common/catalog"; +import { cloneDeep } from "lodash"; -export class CatalogEntityRegistry { - protected sources = observable.map>(); +type SpecFromEntity = Entity extends CatalogEntity ? Spec : never; - constructor(private categoryRegistry: CatalogCategoryRegistry) { - makeObservable(this); - } - - @action addObservableSource(id: string, source: IObservableArray) { - this.sources.set(id, computed(() => source)); - } - - @action addComputedSource(id: string, source: IComputedValue) { - this.sources.set(id, source); - } - - @action removeSource(id: string) { - this.sources.delete(id); - } - - @computed get items(): CatalogEntity[] { - const allItems = Array.from(iter.flatMap(this.sources.values(), source => source.get())); - - return allItems.filter((entity) => this.categoryRegistry.getCategoryForEntity(entity) !== undefined); - } - - getItemsForApiKind(apiVersion: string, kind: string): T[] { - const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); - - return items as T[]; - } +interface EntityEnhancers { + status: IComputedValue, + spec: IComputedValue>>[]; } -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); +export class CatalogEntityRegistry extends Singleton { + protected sources = observable.map>([], { deep: true }); + protected computedEnhancers = new ExtendedObservableMap(); + + addObservableSource(id: string, source: IObservableArray): Disposer { + return this.addComputedSource(id, computed(() => source)); + } + + addComputedSource(id: string, source: IComputedValue): Disposer { + this.sources.set(id, source); + + return () => this.sources.delete(id); + } + + @computed private get rawItems() { + const allItems = Array.from(iter.flatMap(this.sources.values(), source => source.get())); + const res: CatalogEntity[] = []; + + for (const entity of allItems) { + const enhancers = CatalogCategoryRegistry.getInstance().getEnhancerForEntity(entity); + + if (!enhancers) { + continue; + } + + this.computedEnhancers.getOrInsert(entity.metadata.uid, () => ({ + status: enhancers.status(entity), + spec: enhancers.spec.map(enhancer => enhancer(entity)), + })); + } + + return res; + } + + @computed get items(): CatalogEntityComputed[] { + const res: CatalogEntityComputed[] = []; + + for (const { spec, ...entity } of this.rawItems) { + const enhancers = this.computedEnhancers.get(entity.metadata.uid); + + res.push({ + status: enhancers.status.get(), + spec: this.foldSpecs(spec, enhancers.spec), + ...entity + }); + } + + return res; + } + + private foldSpecs(spec: CatalogEntitySpec, enhancers: IComputedValue>[]): CatalogEntitySpec { + const res = cloneDeep(spec); + + for (const enhancer of enhancers) { + Object.assign(res, enhancer.get()); + } + + return res; + } +} diff --git a/src/main/catalog/catalog-entity.ts b/src/main/catalog/catalog-entity.ts new file mode 100644 index 0000000000..5c4c196423 --- /dev/null +++ b/src/main/catalog/catalog-entity.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { CatalogEntityKindData, CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../../common/catalog"; + +export type { CatalogEntityKindData } from "../../common/catalog"; + +export interface CatalogEntity< + Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, + Spec extends CatalogEntitySpec = CatalogEntitySpec, +> extends CatalogEntityKindData { + readonly metadata: Metadata; + readonly spec: Spec; +} + +export interface CatalogEntityComputed< + Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, + Spec extends CatalogEntitySpec = CatalogEntitySpec, + Status extends CatalogEntityStatus = CatalogEntityStatus, +> extends CatalogEntity { + readonly status: Status; +} diff --git a/src/main/catalog/index.ts b/src/main/catalog/index.ts index 13e660b878..9983204070 100644 --- a/src/main/catalog/index.ts +++ b/src/main/catalog/index.ts @@ -20,3 +20,5 @@ */ export * from "./catalog-entity-registry"; +export * from "./catalog-category-registry"; +export * from "./catalog-entity"; diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index b0eb2e7471..41f0f405ed 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -22,180 +22,153 @@ import "../common/cluster-ipc"; import type http from "http"; import { ipcMain } from "electron"; -import { action, autorun, makeObservable, reaction } from "mobx"; -import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; -import type { Cluster } from "./cluster"; +import { computed, makeObservable, observable } from "mobx"; +import { ClusterModel, ClusterPreferencesStore, getClusterIdFromHost } from "../common/cluster-store"; +import { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; -import { Singleton } from "../common/utils"; -import { catalogEntityRegistry } from "./catalog"; -import { KubernetesCluster, KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; +import { noop, Singleton } from "../common/utils"; +import { CatalogCategoryRegistry, CatalogEntity } from "./catalog"; +import type { KubernetesClusterSpec } from "../common/catalog-entities/kubernetes-cluster"; +import type { CatalogEntityMetadata } from "../common/catalog"; export class ClusterManager extends Singleton { - private store = ClusterStore.getInstance(); + protected clusters = observable.map(); constructor() { super(); makeObservable(this); - this.bindEvents(); - } - private bindEvents() { - // reacting to every cluster's state change and total amount of items - reaction( - () => this.store.clustersList.map(c => c.getState()), - () => this.updateCatalog(this.store.clustersList), - { fireImmediately: true, } - ); + CatalogCategoryRegistry.getInstance().add({ + apiVersion: "catalog.k8slens.dev/v1alpha1", + kind: "CatalogCategory", + metadata: { + name: "Kubernetes Clusters", + }, + spec: { + group: "entity.k8slens.dev", + versions: [ + { + version: "v1alpha1", + getStatus: (entity: CatalogEntity) => { + const cluster = new Cluster({ + id: entity.metadata.uid, + preferences: { + clusterName: entity.metadata.name + }, + kubeConfigPath: entity.spec.kubeconfigPath, + contextName: entity.spec.kubeconfigContext + }); - reaction(() => catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { - this.syncClustersFromCatalog(entities); - }); + this.clusters.set(entity.metadata.uid, cluster); - // auto-stop removed clusters - autorun(() => { - const removedClusters = Array.from(this.store.removedClusters.values()); - - if (removedClusters.length > 0) { - const meta = removedClusters.map(cluster => cluster.getMeta()); - - logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); - removedClusters.forEach(cluster => cluster.disconnect()); - this.store.removedClusters.clear(); + return computed(() => ({ + phase: cluster.disconnected ? "disconnected" : "connected", + active: !cluster.disconnected, + })); + }, + }, + ], + names: { + kind: "KubernetesCluster" + } } - }, { - delay: 250 }); + CatalogCategoryRegistry.getInstance().registerSpecEnhancer( + "entity.k8slens.dev/v1alpha1", + "KubernetesCluster", + (entity: CatalogEntity) => { + if (entity.spec.metrics) { + return computed(() => ({})); + } + + const preferences = ClusterPreferencesStore.getInstance().getById(entity.metadata.uid); + + return computed(() => ({ + metrics: { + source: "local", + prometheus: { + type: preferences.prometheusProvider?.type, + address: preferences.prometheus, + }, + } + })); + } + ); + ipcMain.on("network:offline", this.onNetworkOffline); ipcMain.on("network:online", this.onNetworkOnline); } - @action - protected updateCatalog(clusters: Cluster[]) { - for (const cluster of clusters) { - const index = catalogEntityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id); - - if (index !== -1) { - const entity = catalogEntityRegistry.items[index] as KubernetesCluster; - - entity.status.phase = cluster.disconnected ? "disconnected" : "connected"; - entity.status.active = !cluster.disconnected; - - if (cluster.preferences?.clusterName) { - entity.metadata.name = cluster.preferences.clusterName; - } - - entity.spec.metrics ||= { source: "local" }; - - if (entity.spec.metrics.source === "local") { - const prometheus: KubernetesClusterPrometheusMetrics = entity.spec?.metrics?.prometheus || {}; - - prometheus.type = cluster.preferences.prometheusProvider?.type; - prometheus.address = cluster.preferences.prometheus; - entity.spec.metrics.prometheus = prometheus; - } - - catalogEntityRegistry.items.splice(index, 1, entity); - } - } - } - - @action syncClustersFromCatalog(entities: KubernetesCluster[]) { - for (const entity of entities) { - const cluster = this.store.getById(entity.metadata.uid); - - if (!cluster) { - this.store.addCluster({ - id: entity.metadata.uid, - preferences: { - clusterName: entity.metadata.name - }, - kubeConfigPath: entity.spec.kubeconfigPath, - contextName: entity.spec.kubeconfigContext - }); - } else { - cluster.kubeConfigPath = entity.spec.kubeconfigPath; - cluster.contextName = entity.spec.kubeconfigContext; - - entity.status = { - phase: cluster.disconnected ? "disconnected" : "connected", - active: !cluster.disconnected - }; - } - } - } - protected onNetworkOffline = () => { logger.info("[CLUSTER-MANAGER]: network is offline"); - this.store.clustersList.forEach((cluster) => { + + for (const cluster of this.clusters.values()) { if (!cluster.disconnected) { cluster.online = false; cluster.accessible = false; - cluster.refreshConnectionStatus().catch((e) => e); + cluster.refreshConnectionStatus().catch(noop); } - }); + } }; protected onNetworkOnline = () => { logger.info("[CLUSTER-MANAGER]: network is online"); - this.store.clustersList.forEach((cluster) => { + + for (const cluster of this.clusters.values()) { if (!cluster.disconnected) { - cluster.refreshConnectionStatus().catch((e) => e); + cluster.refreshConnectionStatus().catch(noop); } - }); + } }; stop() { - this.store.clusters.forEach((cluster: Cluster) => { + for (const cluster of this.clusters.values()) { cluster.disconnect(); - }); + } } getClusterForRequest(req: http.IncomingMessage): Cluster { - let cluster: Cluster = null; - // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { - const clusterId = req.url.split("/")[1]; - - cluster = this.store.getById(clusterId); + const cluster = this.clusters.get(req.url.split("/")[1]); if (cluster) { // we need to swap path prefix so that request is proxied to kube api - req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); + req.url = req.url.replace(`/${cluster.id}`, apiKubePrefix); } - } else if (req.headers["x-cluster-id"]) { - cluster = this.store.getById(req.headers["x-cluster-id"].toString()); - } else { - const clusterId = getClusterIdFromHost(req.headers.host); - cluster = this.store.getById(clusterId); + return cluster; } - return cluster; + if (req.headers["x-cluster-id"]) { + return this.clusters.get(req.headers["x-cluster-id"].toString()); + } + + return this.clusters.get(getClusterIdFromHost(req.headers.host)); + } + + getById(id: string): Cluster { + return this.clusters.get(id); } } -export function catalogEntityFromCluster(cluster: Cluster) { - return new KubernetesCluster({ +export function catalogEntityFromCluster(cluster: ClusterModel): CatalogEntity { + return { + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", metadata: { uid: cluster.id, - name: cluster.name, + name: cluster.contextName, source: "local", labels: { - distro: cluster.distribution, + distro: cluster.metadata.distribution?.toString() || "unknown", } }, spec: { kubeconfigPath: cluster.kubeConfigPath, kubeconfigContext: cluster.contextName }, - status: { - phase: cluster.disconnected ? "disconnected" : "connected", - reason: "", - message: "", - active: !cluster.disconnected - } - }); + }; } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 09d53ad5cf..cc4719c2a9 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -20,8 +20,8 @@ */ import { ipcMain } from "electron"; -import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store"; import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; +import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store"; import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; @@ -167,12 +167,6 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable isGlobalWatchEnabled = false; - /** - * Preferences - * - * @observable - */ - @observable preferences: ClusterPreferences = {}; /** * Metadata * diff --git a/src/main/index.ts b/src/main/index.ts index 271010d922..065e81af38 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,7 +35,7 @@ import { shellSync } from "./shell-sync"; import { mangleProxyEnv } from "./proxy-env"; import { registerFileProtocol } from "../common/register-protocol"; import logger from "./logger"; -import { ClusterStore } from "../common/cluster-store"; +import { ClusterPreferencesStore } from "../common/cluster-store"; import { UserStore } from "../common/user-store"; import { appEventBus } from "../common/event-bus"; import { ExtensionLoader } from "../extensions/extension-loader"; @@ -50,12 +50,13 @@ import { bindBroadcastHandlers } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { pushCatalogToRenderer } from "./catalog-pusher"; -import { catalogEntityRegistry } from "./catalog"; +import { CatalogCategoryRegistry, CatalogEntityRegistry } from "./catalog"; import { HotbarStore } from "../common/hotbar-store"; import { HelmRepoManager } from "./helm/helm-repo-manager"; import { KubeconfigSyncManager } from "./catalog-sources"; import { handleWsUpgrade } from "./proxy/ws-upgrade"; import configurePackages from "../common/configure-packages"; +import { initCatalogCategories } from "./initializers/catalog-categories"; const workingDir = path.join(app.getPath("appData"), appName); const cleanup = disposer(); @@ -112,6 +113,10 @@ app.on("second-instance", (event, argv) => { }); app.on("ready", async () => { + CatalogCategoryRegistry.createInstance(); + initCatalogCategories(); + CatalogEntityRegistry.createInstance(); + logger.info(`🚀 Starting ${productName} from "${workingDir}"`); logger.info("🐚 Syncing shell environment"); await shellSync(); @@ -125,7 +130,7 @@ app.on("ready", async () => { registerFileProtocol("static", __static); const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); + const clusterStore = ClusterPreferencesStore.createInstance(); const hotbarStore = HotbarStore.createInstance(); const extensionsStore = ExtensionsStore.createInstance(); const filesystemStore = FilesystemProvisionerStore.createInstance(); @@ -190,7 +195,7 @@ app.on("ready", async () => { } ipcMain.on(IpcRendererNavigationEvents.LOADED, () => { - cleanup.push(pushCatalogToRenderer(catalogEntityRegistry)); + cleanup.push(pushCatalogToRenderer()); KubeconfigSyncManager.getInstance().startSync(); startUpdateChecking(); LensProtocolRouterMain.getInstance().rendererLoaded = true; diff --git a/src/main/initializers/catalog-categories.ts b/src/main/initializers/catalog-categories.ts new file mode 100644 index 0000000000..babcf518cf --- /dev/null +++ b/src/main/initializers/catalog-categories.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { computed } from "mobx"; +import URLParse from "url-parse"; +import type { CatalogEntityMetadata } from "../../common/catalog"; +import type { WebLinkSpec } from "../../common/catalog-entities"; +import { CatalogCategoryRegistry, CatalogEntity } from "../catalog"; + +function isValid(url: string): boolean { + try { + new URLParse(url); + + return true; + } catch { + return false; + } +} + +export function initCatalogCategories() { + // KubernetesCluster is done in "cluster-manager.ts" + + CatalogCategoryRegistry.getInstance().add({ + apiVersion: "catalog.k8slens.dev/v1alpha1", + kind: "WebLink", + metadata: { + name: "Web Links", + }, + spec: { + group: "entity.k8slens.dev", + versions: [ + { + version: "v1alpha1", + getStatus: (entity: CatalogEntity) => computed(() => ({ + phase: isValid(entity.spec.url) ? "valid" : "invalid", + })), + } + ], + names: { + kind: "WebLink" + } + } + }); +} diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts new file mode 100644 index 0000000000..8d444cc719 --- /dev/null +++ b/src/main/initializers/index.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export * from "./catalog-categories"; 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 df51510f23..ee400cc5c1 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -26,14 +26,14 @@ import path from "path"; import { app, remote } from "electron"; import { migration } from "../migration-wrapper"; import fse from "fs-extra"; -import { ClusterModel, ClusterStore } from "../../common/cluster-store"; +import { ClusterModel, ClusterPreferencesStore } from "../../common/cluster-store"; import { loadConfig } from "../../common/kube-helpers"; export default migration({ version: "3.6.0-beta.1", run(store, printLog) { const userDataPath = (app || remote.app).getPath("userData"); - const kubeConfigBase = ClusterStore.getCustomKubeConfigPath(""); + const kubeConfigBase = ClusterPreferencesStore.getCustomKubeConfigPath(""); const storedClusters: ClusterModel[] = store.get("clusters") || []; if (!storedClusters.length) return; @@ -47,7 +47,7 @@ export default migration({ */ try { // take the embedded kubeconfig and dump it into a file - cluster.kubeConfigPath = ClusterStore.embedCustomKubeConfig(cluster.id, cluster.kubeConfig); + cluster.kubeConfigPath = ClusterPreferencesStore.embedCustomKubeConfig(cluster.id, cluster.kubeConfig); cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext(); delete cluster.kubeConfig; diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts index 461d6e1886..9de6920fb8 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -21,7 +21,7 @@ // Cleans up a store that had the state related data stored import type { Hotbar } from "../../common/hotbar-store"; -import { ClusterStore } from "../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../common/cluster-store"; import { migration } from "../migration-wrapper"; import { v4 as uuid } from "uuid"; @@ -30,7 +30,7 @@ export default migration({ run(store) { const hotbars: Hotbar[] = []; - ClusterStore.getInstance().clustersList.forEach((cluster: any) => { + ClusterPreferencesStore.getInstance().clustersList.forEach((cluster: any) => { const name = cluster.workspace; if (!name) return; diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts index 33e39f2a13..944cd16f2b 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -20,8 +20,8 @@ */ import type { Hotbar } from "../../common/hotbar-store"; +import { CatalogEntityRegistry } from "../../renderer/catalog"; import { migration } from "../migration-wrapper"; -import { catalogEntityRegistry } from "../../renderer/api/catalog-entity-registry"; export default migration({ version: "5.0.0-beta.5", @@ -30,7 +30,7 @@ export default migration({ hotbars.forEach((hotbar, hotbarIndex) => { hotbar.items.forEach((item, itemIndex) => { - const entity = catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item?.entity.uid); + const entity = CatalogEntityRegistry.getInstance().items.find((entity) => entity.metadata.uid === item?.entity.uid); if (!entity) { // Clear disabled item diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 38396cc608..bce6a4f4e7 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -32,7 +32,7 @@ import { render, unmountComponentAtNode } from "react-dom"; import { delay } from "../common/utils"; import { isMac, isDevelopment } from "../common/vars"; import { HotbarStore } from "../common/hotbar-store"; -import { ClusterStore } from "../common/cluster-store"; +import { ClusterPreferencesStore } from "../common/cluster-store"; import { UserStore } from "../common/user-store"; import { ExtensionDiscovery } from "../extensions/extension-discovery"; import { ExtensionLoader } from "../extensions/extension-loader"; @@ -48,11 +48,11 @@ import configurePackages from "../common/configure-packages"; configurePackages(); -/** - * If this is a development buid, wait a second to attach - * Chrome Debugger to renderer process - * https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode - */ + + + + + async function attachChromeDebugger() { if (isDevelopment) { await delay(1000); @@ -73,7 +73,7 @@ export async function bootstrap(App: AppComponent) { ExtensionDiscovery.createInstance().init(); const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); + const clusterStore = ClusterPreferencesStore.createInstance(); const extensionsStore = ExtensionsStore.createInstance(); const filesystemStore = FilesystemProvisionerStore.createInstance(); const themeStore = ThemeStore.createInstance(); @@ -102,7 +102,7 @@ export async function bootstrap(App: AppComponent) { window.addEventListener("message", (ev: MessageEvent) => { if (ev.data === "teardown") { UserStore.getInstance(false)?.unregisterIpcListener(); - ClusterStore.getInstance(false)?.unregisterIpcListener(); + ClusterPreferencesStore.getInstance(false)?.unregisterIpcListener(); unmountComponentAtNode(rootElem); window.location.href = "about:blank"; } diff --git a/src/renderer/catalog-entities/index.ts b/src/renderer/catalog-entities/index.ts new file mode 100644 index 0000000000..856ea1313d --- /dev/null +++ b/src/renderer/catalog-entities/index.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export * from "./kubernetes-cluster"; diff --git a/src/renderer/catalog-entities/kubernetes-cluster.ts b/src/renderer/catalog-entities/kubernetes-cluster.ts new file mode 100644 index 0000000000..6ff8fcc677 --- /dev/null +++ b/src/renderer/catalog-entities/kubernetes-cluster.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { CatalogEntityMetadata } from "../../common/catalog"; +import type { KubernetesClusterSpec, KubernetesClusterStatus } from "../../common/catalog-entities"; +import { clusterActivateHandler, clusterDisconnectHandler } from "../../common/cluster-ipc"; +import { requestMain } from "../../common/ipc"; +import { CatalogEntity, CatalogEntityActionContext } from "../catalog/catalog-entity"; + +export class KubernetesCluster extends CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "KubernetesCluster"; + + async connect(): Promise { + return requestMain(clusterActivateHandler, this.metadata.uid, false); + } + + async disconnect(): Promise { + return requestMain(clusterDisconnectHandler, this.metadata.uid, false); + } + + onRun = (context: CatalogEntityActionContext) => { + context.navigate(`/cluster/${this.metadata.uid}`); + }; +} diff --git a/src/extensions/core-api/catalog.ts b/src/renderer/catalog-entities/web-link.ts similarity index 67% rename from src/extensions/core-api/catalog.ts rename to src/renderer/catalog-entities/web-link.ts index 14ba0c94d9..0cb7d86275 100644 --- a/src/extensions/core-api/catalog.ts +++ b/src/renderer/catalog-entities/web-link.ts @@ -19,17 +19,15 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import type { CatalogEntityMetadata } from "../../common/catalog"; +import type { WebLinkSpec, WebLinkStatus } from "../../common/catalog-entities"; +import { CatalogEntity } from "../catalog"; -import type { CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry as registry } from "../../main/catalog"; +export class WebLink extends CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "WebLink"; -export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; -export * from "../../common/catalog-entities"; - -export class CatalogEntityRegistry { - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); - } + onRun = () => { + window.open(this.spec.url, "_blank"); + }; } - -export const catalogEntities = new CatalogEntityRegistry(); diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/catalog/__tests__/catalog-entity-registry.test.ts similarity index 83% rename from src/renderer/api/__tests__/catalog-entity-registry.test.ts rename to src/renderer/catalog/__tests__/catalog-entity-registry.test.ts index 8a26aabc00..d132555003 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/catalog/__tests__/catalog-entity-registry.test.ts @@ -21,20 +21,32 @@ import { CatalogEntityRegistry } from "../catalog-entity-registry"; import "../../../common/catalog-entities"; -import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; -import type { CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; +import { CatalogCategoryRegistry } from "../catalog-category-registry"; +import type { CatalogEntity } from "../catalog-entity"; class TestCatalogEntityRegistry extends CatalogEntityRegistry { - replaceItems(items: Array) { + replaceItems(items: CatalogEntity[]) { this.rawItems.replace(items); } } describe("CatalogEntityRegistry", () => { + beforeEach(() => { + CatalogCategoryRegistry.createInstance(); + TestCatalogEntityRegistry.createInstance(); + }); + + afterEach(() => { + TestCatalogEntityRegistry.resetInstance(); + CatalogCategoryRegistry.resetInstance(); + }); + describe("updateItems", () => { it("adds new catalog item", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + const catalog = TestCatalogEntityRegistry.getInstance(); const items = [{ + id: "123", + name: "foobar", apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", metadata: { @@ -53,6 +65,8 @@ describe("CatalogEntityRegistry", () => { expect(catalog.items.length).toEqual(1); items.push({ + id: "456", + name: "barbaz", apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", metadata: { @@ -72,10 +86,12 @@ describe("CatalogEntityRegistry", () => { }); it("updates existing items", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + const catalog = TestCatalogEntityRegistry.getInstance(); const items = [{ + id: "123", apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", + name: "foobar", metadata: { uid: "123", name: "foobar", @@ -100,11 +116,13 @@ describe("CatalogEntityRegistry", () => { }); it("removes deleted items", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + const catalog = TestCatalogEntityRegistry.getInstance(); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", + id: "123", + name: "foobar", metadata: { uid: "123", name: "foobar", @@ -119,6 +137,8 @@ describe("CatalogEntityRegistry", () => { { apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", + id: "456", + name: "barbaz", metadata: { uid: "456", name: "barbaz", @@ -142,11 +162,13 @@ describe("CatalogEntityRegistry", () => { describe("items", () => { it("does not return items without matching category", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + const catalog = TestCatalogEntityRegistry.getInstance(); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", + id: "123", + name: "foobar", metadata: { uid: "123", name: "foobar", @@ -161,6 +183,8 @@ describe("CatalogEntityRegistry", () => { { apiVersion: "entity.k8slens.dev/v1alpha1", kind: "FooBar", + id: "456", + name: "barbaz", metadata: { uid: "456", name: "barbaz", diff --git a/src/renderer/catalog/catalog-categories.ts b/src/renderer/catalog/catalog-categories.ts new file mode 100644 index 0000000000..47b277a6b6 --- /dev/null +++ b/src/renderer/catalog/catalog-categories.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { ObservableSet } from "mobx"; +import type { CatalogCategoryRegistration as CommonCatalogCategoryRegistration, CatalogCategorySpecVersion as CommonCatalogCategorySpecVersion, CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus, CategoryMetadata as CommonCategoryMetadata } from "../../common/catalog"; +import type { OnContextMenuOpen, OnAddMenuOpen, OnSettingsOpen, CategoryHandler, CatalogEntity } from "./catalog-entity"; +import type { Rest } from "../../common/ipc"; +import type { navigate } from "../navigation"; + +type KeysMatching = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T]; +type KeysNotMatching = { [K in keyof T]-?: T[K] extends V ? never : K }[keyof T]; + +export type CategoryHandlers = { + [HandlerName in KeysMatching>]?: Handlers[HandlerName] extends ObservableSet ? Handler : never; +}; +export type CategoryHandlerNames = keyof CategoryHandlers; +export type CatalogHandler = CategoryHandlers[Name]; + +export type EntityContextHandlers = keyof EntityContextGetters; +export type GlobalContextHandlers = keyof GlobalContextGetters; + +type EntityContextGetters = { + [HandlerName in KeysMatching any>>]: () => Rest>; +}; + +type GlobalContextGetters = { + [HandlerName in KeysNotMatching any>>]: () => Parameters; +}; + +export const EntityContexts: EntityContextGetters = { + onContextMenuOpen: () => [{ navigate }], + onSettingsOpen: () => [{ navigate }], +}; + +export const GlobalContexts: GlobalContextGetters = { + onCatalogAddMenu: () => [{ navigate }], +}; + +export interface CategoryMetadata extends CommonCategoryMetadata { + icon: string; +} + +type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; +type ExtractEntityStatusType = Entity extends CatalogEntity ? Status : never; +type ExtractEntitySpecType = Entity extends CatalogEntity ? Spec : never; + +export interface CatalogEntityData< + Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, + Status extends CatalogEntityStatus = CatalogEntityStatus, + Spec extends CatalogEntitySpec = CatalogEntitySpec, +> { + metadata: Metadata; + status: Status; + spec: Spec; +} + +export type CatalogEntityConstructor = ( + (new (data: CatalogEntityData< + ExtractEntityMetadataType, + ExtractEntityStatusType, + ExtractEntitySpecType + >) => Entity) +); + +export interface CatalogCategorySpecVersion extends CommonCatalogCategorySpecVersion { + entityConstructor: CatalogEntityConstructor, +} + +export type CatalogCategoryRegistration = CommonCatalogCategoryRegistration; + +export interface Handlers { + onContextMenuOpen: ObservableSet>; + onSettingsOpen: ObservableSet>; + onCatalogAddMenu: ObservableSet; +} + + +export type Filtered = Handler extends ((...args: any[]) => (infer T)[]) ? (...args: Parameters) => Omit[] : Handler; diff --git a/src/renderer/catalog/catalog-category-registry.ts b/src/renderer/catalog/catalog-category-registry.ts new file mode 100644 index 0000000000..36a2447593 --- /dev/null +++ b/src/renderer/catalog/catalog-category-registry.ts @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable, when } from "mobx"; +import { disposer, Disposer } from "../../common/utils"; +import { parseApiVersion, CatalogCategoryRegistry as CommonCatalogCategoryRegistry } from "../../common/catalog"; +import type { AddMenuEntry, CatalogEntity, MenuEntry, SettingsMenu } from "./catalog-entity"; +import { CatalogCategoryRegistration, CatalogHandler, CategoryHandlerNames, CategoryHandlers, EntityContextHandlers, EntityContexts, Filtered, GlobalContextHandlers, GlobalContexts, Handlers } from "./catalog-categories"; +import { once } from "lodash"; +import { ConfirmDialog } from "../components/confirm-dialog"; + +export interface CatalogCategory extends CatalogCategoryRegistration { + handlers: Handlers, +} + +export type TransformedMenuItem = ReturnType; +export type TransformedSettingsMenu = ReturnType; + +function getOnClick(raw: Omit): () => void { + if (raw.confirm) { + return () => ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: raw.onClick, + message: raw.confirm.message + }); + } + + return raw.onClick; +} + +const tranformations = { + onContextMenuOpen: (entity: CatalogEntity, raw: MenuEntry) => { + if (raw.onlyVisibleForSource && raw.onlyVisibleForSource === entity.metadata.source) { + return null; + } + + return { + title: raw.title, + onClick: getOnClick(raw), + }; + }, + onSettingsOpen: (entity: CatalogEntity, raw: SettingsMenu) => raw, + onCatalogAddMenu: (raw: AddMenuEntry) => ({ + title: raw.title, + onClick: getOnClick(raw), + }) +}; + +export class CatalogCategoryRegistry extends CommonCatalogCategoryRegistry { + protected register(registration: CatalogCategoryRegistration): CatalogCategory { + return { + handlers: { + onCatalogAddMenu: observable.set(), + onContextMenuOpen: observable.set(), + onSettingsOpen: observable.set(), + }, + ...registration + }; + } + + /** + * Gets the `CatalogCategory` once it has been registered + * @param apiVersion the ApiVersion string of the category + * @param kind the kind of entity that is desired + */ + registerHandler(apiVersion: string, kind: string, handlerName: CategoryHandlerNames, handler: CatalogHandler): Disposer { + const { group, version } = parseApiVersion(apiVersion, false); + + if (version) { + // only one version to do + return disposer( + when( + () => this.hasForGroupKind(group, version, kind), + () => { + this.groupVersionKinds + .get(group) + .get(version) + .get(kind) + .handlers[handlerName].add(handler as any); + }, + ), + once(() => this.groupVersionKinds.get(group)?.get(version)?.delete(kind)), + ); + } + + throw new Error("Not providing a version for groups is not supported at this time"); + // This would requiring observing future additions to the second level of the map + // and waiting for them to add the kind + // all wrapped up in disposers + } + + runEntityHandlersFor(entity: CatalogEntity, handlerName: "onContextMenuOpen"): ReturnType[]; + runEntityHandlersFor(entity: CatalogEntity, handlerName: "onSettingsOpen"): ReturnType[]; + runEntityHandlersFor(entity: CatalogEntity, handlerName: EntityContextHandlers): ReturnType> { + const category = this.getRegistered(entity.apiVersion, entity.kind); + const res = []; + + for (const handler of category.handlers[handlerName].values()) { + const items = (handler as any)(entity, ...EntityContexts[handlerName]()); + + for (const item of items) { + if (!item) { + continue; + } + + const transformed = tranformations[handlerName](entity, item); + + if (transformed) { + continue; + } + + res.push(transformed as any); + } + } + + return res; + } + + runGlobalHandlersFor({ spec }: CatalogCategoryRegistration, handlerName: "onCatalogAddMenu"): ReturnType; + runGlobalHandlersFor({ spec }: CatalogCategoryRegistration, handlerName: GlobalContextHandlers): ReturnType { + const category = this.getRegistered(spec.group, spec.names.kind); + const res: ReturnType> = []; + + for (const handler of category.handlers[handlerName].values()) { + const items = (handler as any)(...GlobalContexts[handlerName]()); + + for (const item of items) { + if (!item) { + continue; + } + + const transformed = tranformations[handlerName](item); + + if (transformed) { + continue; + } + + res.push(transformed as any); + } + } + + return res; + } +} diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/catalog/catalog-entity-registry.ts similarity index 69% rename from src/renderer/api/catalog-entity-registry.ts rename to src/renderer/catalog/catalog-entity-registry.ts index 63681106bd..9f6fb78ff7 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/catalog/catalog-entity-registry.ts @@ -21,20 +21,22 @@ import { computed, observable, makeObservable } from "mobx"; import { subscribeToBroadcast } from "../../common/ipc"; -import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; -import "../../common/catalog-entities"; -import { iter } from "../utils"; +import { iter, Singleton } from "../utils"; +import type { CatalogEntity } from "./catalog-entity"; +import { CatalogCategoryRegistry } from "./catalog-category-registry"; +import type { CatalogCategoryRegistration } from "./catalog-categories"; -export class CatalogEntityRegistry { - protected rawItems = observable.array([], { deep: true }); +export class CatalogEntityRegistry extends Singleton { + protected rawItems = observable.array([], { deep: true }); @observable protected _activeEntity: CatalogEntity; - constructor(private categoryRegistry: CatalogCategoryRegistry) { + constructor() { + super(); makeObservable(this); } init() { - subscribeToBroadcast("catalog:items", (ev, items: (CatalogEntityData & CatalogEntityKindData)[]) => { + subscribeToBroadcast("catalog:items", (ev, items: CatalogEntity[]) => { this.rawItems.replace(items); }); } @@ -48,11 +50,11 @@ export class CatalogEntityRegistry { } @computed get items() { - return Array.from(iter.filterMap(this.rawItems, rawItem => this.categoryRegistry.getEntityForData(rawItem))); + return Array.from(iter.filter(this.rawItems, item => CatalogCategoryRegistry.getInstance().getCategoryForEntity(item))); } @computed get entities(): Map { - return new Map(this.items.map(item => [item.metadata.uid, item])); + return new Map(this.items.map(item => [item.id, item])); } getById(id: string) { @@ -65,12 +67,10 @@ export class CatalogEntityRegistry { return items as T[]; } - getItemsForCategory(category: CatalogCategory): T[] { - const supportedVersions = category.spec.versions.map((v) => `${category.spec.group}/${v.name}`); + getItemsForCategory(category: CatalogCategoryRegistration): T[] { + const supportedVersions = category.spec.versions.map(({ version }) => `${category.spec.group}/${version}`); const items = this.items.filter((item) => supportedVersions.includes(item.apiVersion) && item.kind === category.spec.names.kind); return items as T[]; } } - -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); diff --git a/src/renderer/catalog/catalog-entity.ts b/src/renderer/catalog/catalog-entity.ts new file mode 100644 index 0000000000..5443b80ca1 --- /dev/null +++ b/src/renderer/catalog/catalog-entity.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { navigate } from "../navigation"; +import { commandRegistry } from "../../extensions/registries"; +import type { CatalogEntityKindData, CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../../common/catalog"; +import type { CatalogEntityData } from "./catalog-categories"; + +export type { CatalogEntityKindData } from "../../common/catalog"; + +export const catalogEntityRunContext = { + navigate: (url: string) => navigate(url), + setCommandPaletteContext: (entity?: CatalogEntity) => { + commandRegistry.activeEntity = entity; + } +}; + +export interface CatalogEntityActionContext { + navigate: (url: string) => void; + setCommandPaletteContext: (context?: CatalogEntity) => void; +} + +export interface MenuEntry { + title: string; + onlyVisibleForSource?: string; // show only if empty or if matches with entity source + onClick: () => void | Promise; + confirm?: { + message: string; + } +} + +export interface AddMenuEntry extends Omit { + icon: string; +} + +export interface CatalogEntitySettingsMenu { + group?: string; + title: string; + components: { + View: React.ComponentType + }; +} + +export interface MenuContext { + navigate: (url: string) => void; +} + +export type OnContextMenuOpen = (ctx: MenuContext) => MenuEntry[]; +export type OnAddMenuOpen = (ctx: MenuContext) => AddMenuEntry[]; + +export type CategoryHandler any> = (entity: CatalogEntity, ...args: Parameters) => ReturnType; + +export interface SettingsContext { +} + +export interface SettingsMenu { + group?: string; + title: string; + components: { + View: React.ComponentType + }; +} + +export type OnSettingsOpen = (ctx: SettingsContext) => SettingsMenu[]; + +function deepFreeze(o: any) { + Object.freeze(o); + + Object.getOwnPropertyNames(o).forEach(function (prop) { + if (o.hasOwnProperty(prop) + && o[prop] !== null + && (typeof o[prop] === "object" || typeof o[prop] === "function") + && !Object.isFrozen(o[prop])) { + deepFreeze(o[prop]); + } + }); + + return o; +} + +export class CatalogEntity< + Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, + Status extends CatalogEntityStatus = CatalogEntityStatus, + Spec extends CatalogEntitySpec = CatalogEntitySpec, +> implements CatalogEntityKindData { + readonly metadata: Metadata; + readonly status: Status; + readonly spec: Spec; + readonly id: string; + readonly name: string; + readonly apiVersion: string; + readonly kind: string; + + constructor(data: CatalogEntityData) { + // This is done to prevent users from mistaking that they can overright these values to "save" them + this.metadata = deepFreeze(data.metadata); + this.status = deepFreeze(data.status); + this.spec = deepFreeze(data.spec); + this.id = this.metadata.uid; + this.name = this.metadata.name; + } + + onRun?(context: CatalogEntityActionContext): void; +} diff --git a/src/renderer/catalog/index.ts b/src/renderer/catalog/index.ts new file mode 100644 index 0000000000..0725959b93 --- /dev/null +++ b/src/renderer/catalog/index.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export * from "./catalog-category-registry"; +export * from "./catalog-entity-registry"; +export * from "./catalog-entity"; +export * from "./catalog-categories"; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 9c91550d47..eb6805e878 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -27,7 +27,7 @@ import { KubeConfig } from "@kubernetes/client-node"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; import { v4 as uuid } from "uuid"; import { navigate } from "../../navigation"; import { UserStore } from "../../../common/user-store"; @@ -112,7 +112,7 @@ export class AddCluster extends React.Component { }).map(context => { const clusterId = uuid(); const kubeConfig = this.kubeContexts.get(context); - const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder + const kubeConfigPath = ClusterPreferencesStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder return { id: clusterId, @@ -126,7 +126,7 @@ export class AddCluster extends React.Component { }); runInAction(() => { - ClusterStore.getInstance().addClusters(...newClusters); + // ClusterPreferencesStore.getInstance().addClusters(...newClusters); Notifications.ok( <>Successfully imported {newClusters.length} cluster(s) diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx index c839f8dc26..e566fccdac 100644 --- a/src/renderer/components/+catalog/catalog-add-button.tsx +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -26,18 +26,16 @@ import { Icon } from "../icon"; import { disposeOnUnmount, observer } from "mobx-react"; import { observable, reaction, makeObservable } from "mobx"; import { boundMethod } from "../../../common/utils"; -import type { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityAddMenu } from "../../api/catalog-entity"; -import { EventEmitter } from "events"; -import { navigate } from "../../navigation"; +import { AddMenuEntry, CatalogCategoryRegistration, CatalogCategoryRegistry } from "../../catalog"; export type CatalogAddButtonProps = { - category: CatalogCategory + category: CatalogCategoryRegistration }; @observer export class CatalogAddButton extends React.Component { @observable protected isOpen = false; - protected menuItems = observable.array([]); + protected menuItems = observable.array([]); constructor(props: CatalogAddButtonProps) { super(props); @@ -48,15 +46,7 @@ export class CatalogAddButton extends React.Component { disposeOnUnmount(this, [ reaction(() => this.props.category, (category) => { this.menuItems.clear(); - - if (category && category instanceof EventEmitter) { - const context: CatalogEntityAddMenuContext = { - navigate: (url: string) => navigate(url), - menuItems: this.menuItems - }; - - category.emit("onCatalogAddMenu", context); - } + this.menuItems.replace(CatalogCategoryRegistry.getInstance().runGlobalHandlersFor(category, "onCatalogAddMenu")); }, { fireImmediately: true }) ]); } diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts index c681338bf9..113d4ece8c 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -19,33 +19,33 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity"; +import { computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; import { ItemObject, ItemStore } from "../../item.store"; -import { CatalogCategory } from "../../../common/catalog"; -import { autoBind } from "../../../common/utils"; +import { autoBind } from "../../utils"; +import { CatalogEntityRegistry } from "../../catalog/catalog-entity-registry"; +import type { CatalogEntity, CatalogEntityActionContext } from "../../catalog/catalog-entity"; +import { CatalogCategoryRegistration, CatalogCategoryRegistry } from "../../catalog"; export class CatalogEntityItem implements ItemObject { constructor(public entity: CatalogEntity) {} get name() { - return this.entity.metadata.name; + return this.entity.name; } getName() { - return this.entity.metadata.name; + return this.entity.name; } get id() { - return this.entity.metadata.uid; + return this.entity.id; } getId() { return this.id; } - @computed get phase() { + get phase() { return this.entity.status.phase; } @@ -78,9 +78,8 @@ export class CatalogEntityItem implements ItemObject { this.entity.onRun(ctx); } - @action - async onContextMenuOpen(ctx: any) { - return this.entity.onContextMenuOpen(ctx); + onContextMenuOpen() { + return CatalogCategoryRegistry.getInstance().runEntityHandlersFor(this.entity, "onContextMenuOpen"); } } @@ -91,14 +90,14 @@ export class CatalogEntityStore extends ItemStore { autoBind(this); } - @observable activeCategory?: CatalogCategory; + @observable activeCategory?: CatalogCategoryRegistration; @computed get entities() { if (!this.activeCategory) { - return catalogEntityRegistry.items.map(entity => new CatalogEntityItem(entity)); + return CatalogEntityRegistry.getInstance().items.map(entity => new CatalogEntityItem(entity)); } - return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); + return CatalogEntityRegistry.getInstance().getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); } watch() { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index cd04892745..282fdee138 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -25,21 +25,19 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; import { action, makeObservable, observable, reaction, when } from "mobx"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; -import { navigate } from "../../navigation"; import { kebabCase } from "lodash"; import { PageLayout } from "../layout/page-layout"; import { MenuItem, MenuActions } from "../menu"; -import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; +import { catalogEntityRunContext, CatalogCategoryRegistry, TransformedMenuItem } from "../../catalog"; import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; -import { ConfirmDialog } from "../confirm-dialog"; import { Tab, Tabs } from "../tabs"; -import { catalogCategoryRegistry } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import type { ICatalogViewRouteParam } from "./catalog.route"; import { Notifications } from "../notifications"; import { Avatar } from "../avatar/avatar"; +import { boundMethod } from "autobind-decorator"; enum sortBy { name = "name", @@ -52,8 +50,8 @@ interface Props extends RouteComponentProps {} @observer export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; - @observable private contextMenu: CatalogEntityContextMenuContext; @observable activeTab?: string; + menuItems = observable.array(); constructor(props: Props) { super(props); @@ -71,15 +69,11 @@ export class Catalog extends React.Component { } async componentDidMount() { - this.contextMenu = { - menuItems: [], - navigate: (url: string) => navigate(url) - }; this.catalogEntityStore = new CatalogEntityStore(); disposeOnUnmount(this, [ this.catalogEntityStore.watch(), - when(() => catalogCategoryRegistry.items.length > 0, () => { - const item = catalogCategoryRegistry.items.find(i => i.getId() === this.routeActiveTab); + when(() => CatalogCategoryRegistry.getInstance().items.length > 0, () => { + const item = CatalogCategoryRegistry.getInstance().items.find(i => i.id === this.routeActiveTab); if (item || this.routeActiveTab === undefined) { this.activeTab = this.routeActiveTab; @@ -88,9 +82,9 @@ export class Catalog extends React.Component { Notifications.error(

Unknown category: {this.routeActiveTab}

); } }), - reaction(() => catalogCategoryRegistry.items, (items) => { + reaction(() => CatalogCategoryRegistry.getInstance().items, (items) => { if (!this.activeTab && items.length > 0) { - this.activeTab = items[0].getId(); + this.activeTab = items[0].id; this.catalogEntityStore.activeCategory = items[0]; } }), @@ -105,33 +99,16 @@ export class Catalog extends React.Component { item.onRun(catalogEntityRunContext); } - onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message - }); - } else { - menuItem.onClick(); - } - } - get categories() { - return catalogCategoryRegistry.items; + return CatalogCategoryRegistry.getInstance().items; } @action onTabChange = (tabId: string | null) => { - const activeCategory = this.categories.find(category => category.getId() === tabId); + const activeCategory = this.categories.find(category => category.id === tabId); this.catalogEntityStore.activeCategory = activeCategory; - this.activeTab = activeCategory?.getId(); + this.activeTab = activeCategory?.id; }; renderNavigation() { @@ -148,10 +125,10 @@ export class Catalog extends React.Component { { this.categories.map(category => ( )) } @@ -160,14 +137,13 @@ export class Catalog extends React.Component { ); } - renderItemMenu = (item: CatalogEntityItem) => { - const menuItems = this.contextMenu.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === item.entity.metadata.source); - + @boundMethod + renderItemMenu(item: CatalogEntityItem) { return ( - item.onContextMenuOpen(this.contextMenu)}> + this.menuItems.replace(item.onContextMenuOpen())}> { - menuItems.map((menuItem, index) => ( - this.onMenuItemClick(menuItem)}> + this.menuItems.map((menuItem, index) => ( + {menuItem.title} )) @@ -177,10 +153,10 @@ export class Catalog extends React.Component { ); - }; + } renderIcon(item: CatalogEntityItem) { - const category = catalogCategoryRegistry.getCategoryForEntity(item.entity); + const category = CatalogCategoryRegistry.getInstance().getCategoryForEntity(item.entity); if (!category) { return null; diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index cc72c7cc63..d4e0034bdf 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -26,7 +26,7 @@ import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { nodesStore } from "../+nodes/nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; -import { ClusterStore, getHostedCluster } from "../../../common/cluster-store"; +import { getHostedCluster } from "../../../common/cluster-store"; import { interval } from "../../utils"; import { TabLayout } from "../layout/tab-layout"; import { Spinner } from "../spinner"; @@ -87,7 +87,7 @@ export class ClusterOverview extends React.Component { render() { const isLoaded = nodesStore.isLoaded && podsStore.isLoaded; - const isMetricsHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.Cluster); + const isMetricsHidden = getHostedCluster().isMetricHidden(ResourceType.Cluster); return ( diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index 486b6eb105..8b04217dc8 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -28,8 +28,8 @@ import { observer } from "mobx-react"; import { PageLayout } from "../layout/page-layout"; import { navigation } from "../../navigation"; import { Tabs, Tab } from "../tabs"; -import type { CatalogEntity } from "../../api/catalog-entity"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import type { CatalogEntity } from "../../catalog"; +import { CatalogEntityRegistry } from "../../catalog"; import { entitySettingRegistry } from "../../../extensions/registries"; import type { EntitySettingsRouteParams } from "./entity-settings.route"; import { groupBy } from "lodash"; @@ -51,7 +51,7 @@ export class EntitySettings extends React.Component { } get entity(): CatalogEntity { - return catalogEntityRegistry.getById(this.entityId); + return CatalogEntityRegistry.getInstance().getById(this.entityId); } get menuItems() { diff --git a/src/renderer/components/+network-ingresses/ingress-details.tsx b/src/renderer/components/+network-ingresses/ingress-details.tsx index 8e9ddf7bb4..451e374993 100644 --- a/src/renderer/components/+network-ingresses/ingress-details.tsx +++ b/src/renderer/components/+network-ingresses/ingress-details.tsx @@ -36,7 +36,7 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { getBackendServiceNamePort } from "../../api/endpoints/ingress.api"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -130,7 +130,7 @@ export class IngressDetails extends React.Component { "Network", "Duration", ]; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.Ingress); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.Ingress); const { serviceName, servicePort } = ingress.getServiceNamePort(); return ( diff --git a/src/renderer/components/+nodes/node-details.tsx b/src/renderer/components/+nodes/node-details.tsx index df98312d17..5e6166f591 100644 --- a/src/renderer/components/+nodes/node-details.tsx +++ b/src/renderer/components/+nodes/node-details.tsx @@ -39,7 +39,7 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeEventDetails } from "../+events/kube-event-details"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -75,7 +75,7 @@ export class NodeDetails extends React.Component { "Disk", "Pods", ]; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.Node); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.Node); return (
diff --git a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx index c2ae2f672f..e1bf577ca0 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx @@ -36,7 +36,7 @@ import { getDetailsUrl, KubeObjectDetailsProps, KubeObjectMeta } from "../kube-o import type { PersistentVolumeClaim } from "../../api/endpoints"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -64,7 +64,7 @@ export class PersistentVolumeClaimDetails extends React.Component { const metricTabs = [ "Disk" ]; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.VolumeClaim); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.VolumeClaim); return (
diff --git a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx index 96bf692e72..d20a602ce5 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx @@ -40,7 +40,7 @@ import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -70,7 +70,7 @@ export class DaemonSetDetails extends React.Component { const nodeSelector = daemonSet.getNodeSelectors(); const childPods = daemonSetStore.getChildPods(daemonSet); const metrics = daemonSetStore.metrics; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.DaemonSet); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.DaemonSet); return (
diff --git a/src/renderer/components/+workloads-deployments/deployment-details.tsx b/src/renderer/components/+workloads-deployments/deployment-details.tsx index 21bf03bdf5..e4a163f582 100644 --- a/src/renderer/components/+workloads-deployments/deployment-details.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-details.tsx @@ -41,7 +41,7 @@ import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; import { DeploymentReplicaSets } from "./deployment-replicasets"; @@ -74,7 +74,7 @@ export class DeploymentDetails extends React.Component { const childPods = deploymentStore.getChildPods(deployment); const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment); const metrics = deploymentStore.metrics; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.Deployment); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.Deployment); return (
diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx index 1596a6cad7..4856196ffa 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -34,7 +34,7 @@ import type { IMetrics } from "../../api/endpoints/metrics.api"; import { ContainerCharts } from "./container-charts"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; import { LocaleDate } from "../locale-date"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props { pod: Pod; @@ -89,7 +89,7 @@ export class PodDetailsContainer extends React.Component { "Memory", "Filesystem", ]; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.Container); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.Container); return (
diff --git a/src/renderer/components/+workloads-pods/pod-details.tsx b/src/renderer/components/+workloads-pods/pod-details.tsx index 74acfff2cc..4afc050344 100644 --- a/src/renderer/components/+workloads-pods/pod-details.tsx +++ b/src/renderer/components/+workloads-pods/pod-details.tsx @@ -44,7 +44,7 @@ import { PodCharts, podMetricTabs } from "./pod-charts"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -94,7 +94,7 @@ export class PodDetails extends React.Component { const nodeSelector = pod.getNodeSelectors(); const volumes = pod.getVolumes(); const metrics = podsStore.metrics; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.Pod); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.Pod); return (
diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx index e02d23a3d5..e7b67cd335 100644 --- a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx @@ -39,7 +39,7 @@ import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -70,7 +70,7 @@ export class ReplicaSetDetails extends React.Component { const nodeSelector = replicaSet.getNodeSelectors(); const images = replicaSet.getImages(); const childPods = replicaSetStore.getChildPods(replicaSet); - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.ReplicaSet); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.ReplicaSet); return (
diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx index 0342d424e9..fcea676cce 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx @@ -40,7 +40,7 @@ import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { ResourceType } from "../cluster-settings/components/cluster-metrics-setting"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; interface Props extends KubeObjectDetailsProps { } @@ -69,7 +69,7 @@ export class StatefulSetDetails extends React.Component { const nodeSelector = statefulSet.getNodeSelectors(); const childPods = statefulSetStore.getChildPods(statefulSet); const metrics = statefulSetStore.metrics; - const isMetricHidden = ClusterStore.getInstance().isMetricHidden(ResourceType.StatefulSet); + const isMetricHidden = ClusterPreferencesStore.getInstance().isMetricHidden(ResourceType.StatefulSet); return (
diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 2d4ccf3a38..846a296df2 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -30,8 +30,7 @@ import { requestMain, subscribeToBroadcast } from "../../../common/ipc"; import { Icon } from "../icon"; import { Button } from "../button"; import { cssNames, IClassName } from "../../utils"; -import type { Cluster } from "../../../main/cluster"; -import { ClusterId, ClusterStore } from "../../../common/cluster-store"; +import { ClusterId, ClusterPreferencesStore } from "../../../common/cluster-store"; import { CubeSpinner } from "../spinner"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; @@ -50,8 +49,8 @@ export class ClusterStatus extends React.Component { makeObservable(this); } - get cluster(): Cluster { - return ClusterStore.getInstance().getById(this.props.clusterId); + get cluster() { + return ClusterPreferencesStore.getInstance().getById(this.props.clusterId); } @computed get hasErrors(): boolean { diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index ed4cbe2718..655a434aaf 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -26,16 +26,19 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { ClusterStatus } from "./cluster-status"; import { hasLoadedView, initView, refreshViews } from "./lens-views"; import type { Cluster } from "../../../main/cluster"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; import { requestMain } from "../../../common/ipc"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { getMatchedClusterId, navigate } from "../../navigation"; -import { catalogURL } from "../+catalog/catalog.route"; +import { CatalogEntityRegistry } from "../../catalog/catalog-entity-registry"; +import { catalogURL } from "../+catalog"; + +interface Props extends RouteComponentProps { +} @observer -export class ClusterView extends React.Component { - constructor(props: {}) { +export class ClusterView extends React.Component { + constructor(props: Props) { super(props); makeObservable(this); } @@ -44,8 +47,8 @@ export class ClusterView extends React.Component { return getMatchedClusterId(); } - @computed get cluster(): Cluster | undefined { - return ClusterStore.getInstance().getById(this.clusterId); + get cluster(): Cluster { + return ClusterPreferencesStore.getInstance().getById(this.clusterId); } @computed get isReady(): boolean { @@ -64,7 +67,7 @@ export class ClusterView extends React.Component { refreshViews(clusterId); // refresh visibility of active cluster initView(clusterId); // init cluster-view (iframe), requires parent container #lens-views to be in DOM requestMain(clusterActivateHandler, clusterId, false); // activate and fetch cluster's state from main - catalogEntityRegistry.activeEntity = catalogEntityRegistry.getById(clusterId); + CatalogEntityRegistry.getInstance().activeEntity = CatalogEntityRegistry.getInstance().getById(clusterId); }, { fireImmediately: true, }), diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 1ec47a48fc..8cf161906d 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -20,8 +20,9 @@ */ import { observable, when } from "mobx"; -import { ClusterId, ClusterStore, getClusterFrameUrl } from "../../../common/cluster-store"; +import { ClusterId, ClusterPreferencesStore, getClusterFrameUrl } from "../../../common/cluster-store"; import logger from "../../../main/logger"; +import { CatalogEntityRegistry } from "../../catalog"; export interface LensView { isLoaded?: boolean @@ -36,7 +37,13 @@ export function hasLoadedView(clusterId: ClusterId): boolean { } export async function initView(clusterId: ClusterId) { - const cluster = ClusterStore.getInstance().getById(clusterId); + refreshViews(clusterId); + + if (!clusterId || lensViews.has(clusterId)) { + return; + } + + const cluster = CatalogEntityRegistry.getInstance().getById(clusterId); if (!cluster || lensViews.has(clusterId)) { return; @@ -46,7 +53,7 @@ export async function initView(clusterId: ClusterId) { const parentElem = document.getElementById("lens-views"); const iframe = document.createElement("iframe"); - iframe.name = cluster.contextName; + iframe.name = cluster.name; iframe.setAttribute("src", getClusterFrameUrl(clusterId)); iframe.addEventListener("load", () => { logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`); @@ -62,7 +69,7 @@ export async function initView(clusterId: ClusterId) { export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) { await when(() => { - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = ClusterPreferencesStore.getInstance().getById(clusterId); return !cluster || (cluster.disconnected && lensViews.get(clusterId)?.isLoaded); }); @@ -80,7 +87,7 @@ export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrame export function refreshViews(visibleClusterId?: string) { logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${visibleClusterId}`); - const cluster = ClusterStore.getInstance().getById(visibleClusterId); + const cluster = !visibleClusterId ? null : ClusterPreferencesStore.getInstance().getById(visibleClusterId); lensViews.forEach(({ clusterId, view, isLoaded }) => { const isCurrent = clusterId === cluster?.id; diff --git a/src/renderer/components/cluster-settings/cluster-settings.command.ts b/src/renderer/components/cluster-settings/cluster-settings.command.ts index 80d49ae39f..85da2c6dc9 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.command.ts +++ b/src/renderer/components/cluster-settings/cluster-settings.command.ts @@ -22,7 +22,7 @@ import { navigate } from "../../navigation"; import { commandRegistry } from "../../../extensions/registries/command-registry"; import { entitySettingsURL } from "../+entity-settings"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; commandRegistry.add({ id: "cluster.viewCurrentClusterSettings", @@ -30,7 +30,7 @@ commandRegistry.add({ scope: "global", action: () => navigate(entitySettingsURL({ params: { - entityId: ClusterStore.getInstance().active.id + entityId: ClusterPreferencesStore.getInstance().active.id } })), isActive: (context) => !!context.entity diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index d8fd374136..66328418fc 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -20,7 +20,7 @@ */ import React from "react"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../common/cluster-store"; import { ClusterProxySetting } from "./components/cluster-proxy-setting"; import { ClusterNameSetting } from "./components/cluster-name-setting"; import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting"; @@ -30,11 +30,11 @@ import { ShowMetricsSetting } from "./components/show-metrics"; import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting"; import { ClusterKubeconfig } from "./components/cluster-kubeconfig"; import { entitySettingRegistry } from "../../../extensions/registries"; -import type { CatalogEntity } from "../../api/catalog-entity"; +import type { CatalogEntity } from "../../catalog"; function getClusterForEntity(entity: CatalogEntity) { - return ClusterStore.getInstance().getById(entity.metadata.uid); + return ClusterPreferencesStore.getInstance().getById(entity.metadata.uid); } entitySettingRegistry.add([ diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 16c375d666..a3a5d01b9d 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -25,11 +25,11 @@ import { computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import React from "react"; import { commandRegistry } from "../../../extensions/registries/command-registry"; -import { ClusterStore } from "../../../common/cluster-store"; import { CommandOverlay } from "./command-container"; import { broadcastMessage } from "../../../common/ipc"; import { navigate } from "../../navigation"; import { clusterViewURL } from "../cluster-manager/cluster-view.route"; +import { CatalogEntityRegistry } from "../../catalog"; @observer export class CommandDialog extends React.Component { @@ -46,7 +46,7 @@ export class CommandDialog extends React.Component { }; return commandRegistry.getItems().filter((command) => { - if (command.scope === "entity" && !ClusterStore.getInstance().active) { + if (command.scope === "entity" && !CatalogEntityRegistry.getInstance().activeEntity) { return false; } diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index 8c267e8956..eaddcf35b9 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -22,11 +22,7 @@ import React, { DOMAttributes } from "react"; import { makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; - -import type { CatalogEntity, CatalogEntityContextMenuContext } from "../../../common/catalog"; -import { catalogCategoryRegistry } from "../../api/catalog-category-registry"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { navigate } from "../../navigation"; +import { CatalogCategoryRegistry, CatalogEntity, CatalogEntityRegistry, TransformedMenuItem } from "../../catalog"; import { cssNames, IClassName } from "../../utils"; import { Icon } from "../icon"; import { HotbarIcon } from "./hotbar-icon"; @@ -43,23 +39,16 @@ interface Props extends DOMAttributes { @observer export class HotbarEntityIcon extends React.Component { - @observable private contextMenu: CatalogEntityContextMenuContext; + menuItems = observable.array(); constructor(props: Props) { super(props); makeObservable(this); } - componentDidMount() { - this.contextMenu = { - menuItems: [], - navigate: (url: string) => navigate(url) - }; - } - get kindIcon() { const className = "badge"; - const category = catalogCategoryRegistry.getCategoryForEntity(this.props.entity); + const category = CatalogCategoryRegistry.getInstance().getCategoryForEntity(this.props.entity); if (!category) { return ; @@ -67,9 +56,9 @@ export class HotbarEntityIcon extends React.Component { if (category.metadata.icon.includes("; - } else { - return ; } + + return ; } get ledIcon() { @@ -79,7 +68,7 @@ export class HotbarEntityIcon extends React.Component { } isActive(item: CatalogEntity) { - return catalogEntityRegistry.activeEntity?.metadata?.uid == item.getId(); + return CatalogEntityRegistry.getInstance().activeEntity?.metadata?.uid == item.id; } isPersisted(entity: CatalogEntity) { @@ -87,10 +76,6 @@ export class HotbarEntityIcon extends React.Component { } render() { - if (!this.contextMenu) { - return null; - } - const { entity, errorClass, add, remove, index, children, ...elemProps @@ -100,24 +85,16 @@ export class HotbarEntityIcon extends React.Component { active: this.isActive(entity), disabled: !entity }); - const onOpen = async () => { - await entity.onContextMenuOpen(this.contextMenu); - }; const isActive = this.isActive(entity); - const isPersisted = this.isPersisted(entity); - const menuItems = this.contextMenu?.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source); - - if (!isPersisted) { - menuItems.unshift({ + const persistAction = this.isPersisted(entity) + ? ({ title: "Pin to Hotbar", onClick: () => add(entity, index) - }); - } else { - menuItems.unshift({ + }) + : ({ title: "Unpin from Hotbar", onClick: () => remove(entity.metadata.uid) }); - } return ( { source={entity.metadata.source} className={className} active={isActive} - onMenuOpen={onOpen} - menuItems={menuItems} + onMenuOpen={() => this.menuItems.replace(CatalogCategoryRegistry.getInstance().runEntityHandlersFor(entity, "onContextMenuOpen"))} + menuItems={[...this.menuItems, persistAction]} {...elemProps} > { this.ledIcon } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 6dd86414e7..1d0c8caa49 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -23,13 +23,12 @@ import "./hotbar-icon.scss"; import React, { DOMAttributes, useState } from "react"; -import type { CatalogEntityContextMenu } from "../../../common/catalog"; import { cssNames, IClassName } from "../../utils"; -import { ConfirmDialog } from "../confirm-dialog"; import { Menu, MenuItem } from "../menu"; import { MaterialTooltip } from "../material-tooltip/material-tooltip"; import { observer } from "mobx-react"; import { Avatar } from "../avatar/avatar"; +import type { TransformedMenuItem } from "../../catalog"; interface Props extends DOMAttributes { uid: string; @@ -38,27 +37,10 @@ interface Props extends DOMAttributes { onMenuOpen?: () => void; className?: IClassName; active?: boolean; - menuItems?: CatalogEntityContextMenu[]; + menuItems?: TransformedMenuItem[]; disabled?: boolean; } -function onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message - }); - } else { - menuItem.onClick(); - } -} - export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => { const { uid, title, active, className, source, disabled, onMenuOpen, children, ...rest } = props; const id = `hotbarIcon-${uid}`; @@ -95,13 +77,11 @@ export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => { toggleMenu(); }} close={() => toggleMenu()}> - { menuItems.map((menuItem) => { - return ( - onMenuItemClick(menuItem) }> - {menuItem.title} - - ); - })} + {menuItems.map((menuItem) => ( + + {menuItem.title} + + ))}
); diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index d3bb838ac7..6f0179890f 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -26,9 +26,8 @@ import React from "react"; import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store"; -import { CatalogEntity, CatalogEntityContextMenu, catalogEntityRunContext } from "../../api/catalog-entity"; +import { CatalogEntity, MenuEntry, catalogEntityRunContext, CatalogEntityRegistry } from "../../catalog"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; import { HotbarCell } from "./hotbar-cell"; @@ -52,7 +51,7 @@ export class HotbarMenu extends React.Component { return null; } - return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null; + return item ? CatalogEntityRegistry.getInstance().items.find((entity) => entity.metadata.uid === item.entity.uid) : null; } onDragEnd(result: DropResult) { @@ -92,7 +91,7 @@ export class HotbarMenu extends React.Component { @computed get items() { const items = this.hotbar.items; - const activeEntity = catalogEntityRegistry.activeEntity; + const activeEntity = CatalogEntityRegistry.getInstance().activeEntity; if (!activeEntity) return items; @@ -111,7 +110,7 @@ export class HotbarMenu extends React.Component { renderGrid() { return this.items.map((item, index) => { const entity = this.getEntity(item); - const disabledMenuItems: CatalogEntityContextMenu[] = [ + const disabledMenuItems: MenuEntry[] = [ { title: "Unpin from Hotbar", onClick: () => this.removeItem(item.entity.uid) diff --git a/src/renderer/components/layout/__test__/main-layout-header.test.tsx b/src/renderer/components/layout/__test__/main-layout-header.test.tsx index 0a4dc4babd..a3be8ab1a3 100644 --- a/src/renderer/components/layout/__test__/main-layout-header.test.tsx +++ b/src/renderer/components/layout/__test__/main-layout-header.test.tsx @@ -27,7 +27,7 @@ import "@testing-library/jest-dom/extend-expect"; import { MainLayoutHeader } from "../main-layout-header"; import { Cluster } from "../../../../main/cluster"; -import { ClusterStore } from "../../../../common/cluster-store"; +import { ClusterPreferencesStore } from "../../../../common/cluster-store"; import mockFs from "mock-fs"; describe("", () => { @@ -60,7 +60,7 @@ describe("", () => { mockFs(mockOpts); - ClusterStore.createInstance(); + ClusterPreferencesStore.createInstance(); cluster = new Cluster({ id: "foo", @@ -70,7 +70,7 @@ describe("", () => { }); afterEach(() => { - ClusterStore.resetInstance(); + ClusterPreferencesStore.resetInstance(); mockFs.restore(); }); diff --git a/src/renderer/initializers/catalog-categories.ts b/src/renderer/initializers/catalog-categories.ts new file mode 100644 index 0000000000..1894beae88 --- /dev/null +++ b/src/renderer/initializers/catalog-categories.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { clusterDisconnectHandler } from "../../common/cluster-ipc"; +import { ClusterPreferencesStore } from "../../common/cluster-store"; +import { requestMain } from "../../common/ipc"; +import { CatalogCategoryRegistry, MenuContext, MenuEntry } from "../catalog"; +import { KubernetesCluster } from "../catalog-entities"; +import { productName } from "../../common/vars"; +import { WebLink } from "../catalog-entities/web-link"; + +export function initCatalogCategoryHandlers() { + const registry = CatalogCategoryRegistry.getInstance(); + + /** + * KubernetesCluster + */ + registry.add({ + apiVersion: "catalog.k8slens.dev/v1alpha1", + kind: "CatalogCategory", + metadata: { + name: "Kubernetes Clusters", + icon: require(`!!raw-loader!./catalog-icons/kubernetes.svg`).default // eslint-disable-line + }, + spec: { + group: "entity.k8slens.dev", + versions: [ + { + version: "v1alpha1", + entityConstructor: KubernetesCluster, + } + ], + names: { + kind: "KubernetesCluster" + } + } + }); + registry.registerHandler( + "entity.k8slens.dev/v1alpha1", + "KubernetesCluster", + "onCatalogAddMenu", + (ctx: MenuContext) => [ + { + icon: "text_snippet", + title: "Add from kubeconfig", + onClick: () => { + ctx.navigate("/add-cluster"); + } + } + ] + ); + registry.registerHandler( + "entity.k8slens.dev/v1alpha1", + "KubernetesCluster", + "onContextMenuOpen", + (entity: KubernetesCluster, ctx: MenuContext) => { + const res: MenuEntry[] = [ + { + title: "Settings", + onlyVisibleForSource: "local", + onClick: () => ctx.navigate(`/entity/${entity.metadata.uid}/settings`) + } + ]; + + if (entity.metadata.labels["file"]?.startsWith(ClusterPreferencesStore.storedKubeConfigFolder)) { + res.push({ + title: "Delete", + onlyVisibleForSource: "local", + onClick: () => ClusterPreferencesStore.getInstance().removeById(entity.metadata.uid), + confirm: { + message: `Remove Kubernetes Cluster "${entity.metadata.name} from ${productName}?` + } + }); + } + + if (entity.status.phase == "connected") { + res.push({ + title: "Disconnect", + onClick: () => { + ClusterPreferencesStore.getInstance().deactivate(entity.metadata.uid); + requestMain(clusterDisconnectHandler, entity.metadata.uid); + } + }); + } else { + res.push({ + title: "Connect", + onClick: () => { + ctx.navigate(`/cluster/${entity.metadata.uid}`); + } + }); + } + + return res; + } + ); + + /** + * WebLink + */ + registry.add({ + apiVersion: "catalog.k8slens.dev/v1alpha1", + kind: "WebLink", + metadata: { + name: "Web Links", + icon: "link" + }, + spec: { + group: "entity.k8slens.dev", + versions: [ + { + version: "v1alpha1", + entityConstructor: WebLink, + } + ], + names: { + kind: "WebLink" + } + } + }); +} diff --git a/src/common/catalog-entities/icons/kubernetes.svg b/src/renderer/initializers/catalog-icons/kubernetes.svg similarity index 100% rename from src/common/catalog-entities/icons/kubernetes.svg rename to src/renderer/initializers/catalog-icons/kubernetes.svg diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts new file mode 100644 index 0000000000..8d444cc719 --- /dev/null +++ b/src/renderer/initializers/index.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export * from "./catalog-categories"; diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 5ae4b48aad..4edc73a661 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -26,7 +26,7 @@ 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 { ClusterPreferencesStore } from "../../common/cluster-store"; import { navigate } from "../navigation"; import { entitySettingsURL } from "../components/+entity-settings"; @@ -97,7 +97,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.

+

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