diff --git a/extensions/metrics-cluster-feature/package.json b/extensions/metrics-cluster-feature/package.json index 1b1b5850ec..80a8ddd60a 100644 --- a/extensions/metrics-cluster-feature/package.json +++ b/extensions/metrics-cluster-feature/package.json @@ -12,7 +12,6 @@ "dev": "npm run build --watch", "test": "jest --passWithNoTests --env=jsdom src $@" }, - "dependencies": {}, "devDependencies": { "@k8slens/extensions": "file:../../src/extensions/npm/extensions", "jest": "^26.6.3", diff --git a/extensions/metrics-cluster-feature/renderer.tsx b/extensions/metrics-cluster-feature/renderer.tsx index a32296a408..ea8a666103 100644 --- a/extensions/metrics-cluster-feature/renderer.tsx +++ b/extensions/metrics-cluster-feature/renderer.tsx @@ -1,21 +1,55 @@ -import { LensRendererExtension } from "@k8slens/extensions"; +import { LensRendererExtension, Store, Interface, Component } from "@k8slens/extensions"; import { MetricsFeature } from "./src/metrics-feature"; -import React from "react"; export default class ClusterMetricsFeatureExtension extends LensRendererExtension { - clusterFeatures = [ - { - title: "Metrics Stack", - components: { - Description: () => ( - - Enable timeseries data visualization (Prometheus stack) for your cluster. - Install this only if you don't have existing Prometheus stack installed. - You can see preview of manifests here. - - ) - }, - feature: new MetricsFeature() + onActivate() { + const category = Store.catalogCategories.getForGroupKind("entity.k8slens.dev", "KubernetesCluster"); + + if (!category) { + return; } - ]; + + category.on("contextMenuOpen", this.clusterContextMenuOpen.bind(this)); + } + + async clusterContextMenuOpen(cluster: Store.KubernetesCluster, ctx: Interface.CatalogEntityContextMenuContext) { + if (!cluster.status.active) { + return; + } + + const metricsFeature = new MetricsFeature(); + + await metricsFeature.updateStatus(cluster); + + if (metricsFeature.status.installed) { + if (metricsFeature.status.canUpgrade) { + ctx.menuItems.unshift({ + icon: "refresh", + title: "Upgrade Lens Metrics stack", + onClick: async () => { + metricsFeature.upgrade(cluster); + } + }); + } + ctx.menuItems.unshift({ + icon: "toggle_off", + title: "Uninstall Lens Metrics stack", + onClick: async () => { + await metricsFeature.uninstall(cluster); + + Component.Notifications.info(`Lens Metrics has been removed from ${cluster.metadata.name}`, { timeout: 10_000 }); + } + }); + } else { + ctx.menuItems.unshift({ + icon: "toggle_on", + title: "Install Lens Metrics stack", + onClick: async () => { + metricsFeature.install(cluster); + + Component.Notifications.info(`Lens Metrics is now installed to ${cluster.metadata.name}`, { timeout: 10_000 }); + } + }); + } + } } diff --git a/extensions/metrics-cluster-feature/src/metrics-feature.ts b/extensions/metrics-cluster-feature/src/metrics-feature.ts index 43dfd7743c..2f830ae92e 100644 --- a/extensions/metrics-cluster-feature/src/metrics-feature.ts +++ b/extensions/metrics-cluster-feature/src/metrics-feature.ts @@ -49,7 +49,7 @@ export class MetricsFeature extends ClusterFeature.Feature { storageClass: null, }; - async install(cluster: Store.Cluster): Promise { + async install(cluster: Store.KubernetesCluster): Promise { // Check if there are storageclasses const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass); const scs = await storageClassApi.list(); @@ -62,11 +62,11 @@ export class MetricsFeature extends ClusterFeature.Feature { super.applyResources(cluster, path.join(__dirname, "../resources/")); } - async upgrade(cluster: Store.Cluster): Promise { + async upgrade(cluster: Store.KubernetesCluster): Promise { return this.install(cluster); } - async updateStatus(cluster: Store.Cluster): Promise { + async updateStatus(cluster: Store.KubernetesCluster): Promise { try { const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet); const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"}); @@ -87,12 +87,13 @@ export class MetricsFeature extends ClusterFeature.Feature { return this.status; } - async uninstall(cluster: Store.Cluster): Promise { + async uninstall(cluster: Store.KubernetesCluster): Promise { const namespaceApi = K8sApi.forCluster(cluster, K8sApi.Namespace); const clusterRoleBindingApi = K8sApi.forCluster(cluster, K8sApi.ClusterRoleBinding); const clusterRoleApi = K8sApi.forCluster(cluster, K8sApi.ClusterRole); await namespaceApi.delete({name: "lens-metrics"}); await clusterRoleBindingApi.delete({name: "lens-prometheus"}); - await clusterRoleApi.delete({name: "lens-prometheus"}); } + await clusterRoleApi.delete({name: "lens-prometheus"}); + } } diff --git a/extensions/telemetry/package.json b/extensions/telemetry/package.json index 5e32f25b42..a5df1626f7 100644 --- a/extensions/telemetry/package.json +++ b/extensions/telemetry/package.json @@ -13,7 +13,6 @@ "dev": "webpack --watch", "test": "jest --passWithNoTests --env=jsdom src $@" }, - "dependencies": {}, "devDependencies": { "@k8slens/extensions": "file:../../src/extensions/npm/extensions", "@types/analytics-node": "^3.1.3", diff --git a/extensions/telemetry/src/tracker.ts b/extensions/telemetry/src/tracker.ts index 28595a6a93..76b046f63b 100644 --- a/extensions/telemetry/src/tracker.ts +++ b/extensions/telemetry/src/tracker.ts @@ -102,13 +102,12 @@ export class Tracker extends Util.Singleton { } protected reportData() { - const clustersList = Store.clusterStore.enabledClustersList; + const clustersList = Store.catalogEntities.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"); this.event("generic-data", "report", { appVersion: App.version, os: this.os, clustersCount: clustersList.length, - workspacesCount: Store.workspaceStore.enabledWorkspacesList.length, extensions: App.getEnabledExtensions() }); @@ -118,10 +117,10 @@ export class Tracker extends Util.Singleton { }); } - protected reportClusterData(cluster: Store.ClusterModel) { + protected reportClusterData(cluster: Store.KubernetesCluster) { this.event("cluster-data", "report", { id: cluster.metadata.id, - managed: !!cluster.ownerRef, + managed: cluster.metadata.source !== "local", kubernetesVersion: cluster.metadata.version, distribution: cluster.metadata.distribution, nodesCount: cluster.metadata.nodes, diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index ed2340f86f..2d7807e22b 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -26,6 +26,7 @@ describe("Lens cluster pages", () => { const addCluster = async () => { await utils.clickWhatsNew(app); await utils.clickWelcomeNotification(app); + await app.client.waitUntilTextExists("div", "Catalog"); await addMinikubeCluster(app); await waitForMinikubeDashboard(app); await app.client.click('a[href="/nodes"]'); diff --git a/integration/__tests__/workspace.tests.ts b/integration/__tests__/workspace.tests.ts deleted file mode 100644 index 6ad56d5255..0000000000 --- a/integration/__tests__/workspace.tests.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Application } from "spectron"; -import * as utils from "../helpers/utils"; -import { addMinikubeCluster, minikubeReady } from "../helpers/minikube"; -import { exec } from "child_process"; -import * as util from "util"; - -export const promiseExec = util.promisify(exec); - -jest.setTimeout(60000); - -describe("Lens integration tests", () => { - let app: Application; - const ready = minikubeReady("workspace-int-tests"); - - utils.describeIf(ready)("workspaces", () => { - utils.beforeAllWrapped(async () => { - app = await utils.appStart(); - await utils.clickWhatsNew(app); - }); - - utils.afterAllWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); - - const switchToWorkspace = async (name: string) => { - await app.client.click("[data-test-id=current-workspace]"); - await app.client.keys(name); - await app.client.keys("Enter"); - await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name); - }; - - const createWorkspace = async (name: string) => { - await app.client.click("[data-test-id=current-workspace]"); - await app.client.keys("add workspace"); - await app.client.keys("Enter"); - await app.client.keys(name); - await app.client.keys("Enter"); - await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name); - }; - - it("creates new workspace", async () => { - const name = "test-workspace"; - - await createWorkspace(name); - await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name); - }); - - it("edits current workspaces", async () => { - await createWorkspace("to-be-edited"); - await app.client.click("[data-test-id=current-workspace]"); - await app.client.keys("edit current workspace"); - await app.client.keys("Enter"); - await app.client.keys("edited-workspace"); - await app.client.keys("Enter"); - await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", "edited-workspace"); - }); - - it("adds cluster in default workspace", async () => { - await switchToWorkspace("default"); - await addMinikubeCluster(app); - await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); - await app.client.waitForExist(`iframe[name="minikube"]`); - await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active"); - }); - - it("adds cluster in test-workspace", async () => { - await switchToWorkspace("test-workspace"); - await addMinikubeCluster(app); - await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); - await app.client.waitForExist(`iframe[name="minikube"]`); - }); - }); -}); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index e2ca0e0f23..2c62279e4b 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -49,6 +49,8 @@ export async function addMinikubeCluster(app: Application) { } // else the only context, which must be 'minikube', is automatically selected await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) await app.client.click("button.primary"); // add minikube cluster + await app.client.waitUntilTextExists("div.TableCell", "minikube"); + await app.client.click("div.TableRow"); } export async function waitForMinikubeDashboard(app: Application) { diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 14c0ff3334..1417f3f04e 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -80,7 +80,7 @@ export async function appStart() { export async function clickWhatsNew(app: Application) { await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.click("button.primary"); - await app.client.waitUntilTextExists("h5", "Clusters"); + await app.client.waitUntilTextExists("div", "Catalog"); } export async function clickWelcomeNotification(app: Application) { diff --git a/package.json b/package.json index 5fe9b8600a..b70fa7dbfd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.2.0-rc.3", + "version": "5.0.0-alpha.0", "main": "static/build/main.js", "copyright": "© 2021, Mirantis, Inc.", "license": "MIT", @@ -17,7 +17,7 @@ "dev-run": "nodemon --watch static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"", "dev:main": "yarn run compile:main --watch", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", - "dev:extension-types": "yarn run compile:extension-types --watch --progress", + "dev:extension-types": "yarn run compile:extension-types --watch", "compile": "env NODE_ENV=production concurrently yarn:compile:*", "compile:main": "yarn run webpack --config webpack.main.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index e9ee4ee486..ec8e244fd2 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -3,7 +3,6 @@ import mockFs from "mock-fs"; import yaml from "js-yaml"; import { Cluster } from "../../main/cluster"; import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; -import { workspaceStore } from "../workspace-store"; const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); const kubeconfig = ` @@ -77,8 +76,7 @@ describe("empty config", () => { icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig), - workspace: workspaceStore.currentWorkspaceId + kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig) }) ); }); @@ -92,12 +90,6 @@ describe("empty config", () => { expect(storedCluster.enabled).toBe(true); }); - it("adds cluster to default workspace", () => { - const storedCluster = clusterStore.getById("foo"); - - expect(storedCluster.workspace).toBe("default"); - }); - it("removes cluster from store", async () => { await clusterStore.removeById("foo"); expect(clusterStore.getById("foo")).toBeNull(); @@ -106,7 +98,6 @@ describe("empty config", () => { it("sets active cluster", () => { clusterStore.setActive("foo"); expect(clusterStore.active.id).toBe("foo"); - expect(workspaceStore.currentWorkspace.lastActiveClusterId).toBe("foo"); }); }); @@ -119,8 +110,7 @@ describe("empty config", () => { preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig), - workspace: "workstation" + kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig) }), new Cluster({ id: "dev", @@ -128,8 +118,7 @@ describe("empty config", () => { preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig), - workspace: "workstation" + kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig) }) ); }); @@ -139,51 +128,11 @@ describe("empty config", () => { expect(clusterStore.clusters.size).toBe(2); }); - it("gets clusters by workspaces", () => { - const wsClusters = clusterStore.getByWorkspaceId("workstation"); - const defaultClusters = clusterStore.getByWorkspaceId("default"); - - expect(defaultClusters.length).toBe(0); - expect(wsClusters.length).toBe(2); - expect(wsClusters[0].id).toBe("prod"); - expect(wsClusters[1].id).toBe("dev"); - }); - it("check if cluster's kubeconfig file saved", () => { const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); }); - - it("check if reorderring works for same from and to", () => { - clusterStore.swapIconOrders("workstation", 1, 1); - - const clusters = clusterStore.getByWorkspaceId("workstation"); - - expect(clusters[0].id).toBe("prod"); - expect(clusters[0].preferences.iconOrder).toBe(0); - expect(clusters[1].id).toBe("dev"); - expect(clusters[1].preferences.iconOrder).toBe(1); - }); - - it("check if reorderring works for different from and to", () => { - clusterStore.swapIconOrders("workstation", 0, 1); - - const clusters = clusterStore.getByWorkspaceId("workstation"); - - expect(clusters[0].id).toBe("dev"); - expect(clusters[0].preferences.iconOrder).toBe(0); - expect(clusters[1].id).toBe("prod"); - expect(clusters[1].preferences.iconOrder).toBe(1); - }); - - it("check if after icon reordering, changing workspaces still works", () => { - clusterStore.swapIconOrders("workstation", 1, 1); - clusterStore.getById("prod").workspace = "default"; - - expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1); - expect(clusterStore.getByWorkspaceId("default").length).toBe(1); - }); }); }); @@ -486,12 +435,6 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { afterEach(() => { mockFs.restore(); }); - - it("adds cluster to default workspace", async () => { - const storedClusterData = clusterStore.clustersList[0]; - - expect(storedClusterData.workspace).toBe("default"); - }); }); describe("pre 3.6.0-beta.1 config with an existing cluster", () => { diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts deleted file mode 100644 index ae9538ead3..0000000000 --- a/src/common/__tests__/workspace-store.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import mockFs from "mock-fs"; - -jest.mock("electron", () => { - return { - app: { - getVersion: () => "99.99.99", - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, - ipcMain: { - handle: jest.fn(), - on: jest.fn() - } - }; -}); - -import { Workspace, WorkspaceStore } from "../workspace-store"; - -describe("workspace store tests", () => { - describe("for an empty config", () => { - beforeEach(async () => { - WorkspaceStore.resetInstance(); - mockFs({ tmp: { "lens-workspace-store.json": "{}" } }); - - await WorkspaceStore.getInstance().load(); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("default workspace should always exist", () => { - const ws = WorkspaceStore.getInstance(); - - expect(ws.workspaces.size).toBe(1); - expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null); - }); - - it("default workspace should be enabled", () => { - const ws = WorkspaceStore.getInstance(); - - expect(ws.workspaces.size).toBe(1); - expect(ws.getById(WorkspaceStore.defaultId).enabled).toBe(true); - }); - - it("cannot remove the default workspace", () => { - const ws = WorkspaceStore.getInstance(); - - expect(() => ws.removeWorkspaceById(WorkspaceStore.defaultId)).toThrowError("Cannot remove"); - }); - - it("can update workspace description", () => { - const ws = WorkspaceStore.getInstance(); - const workspace = ws.addWorkspace(new Workspace({ - id: "foobar", - name: "foobar", - })); - - workspace.description = "Foobar description"; - ws.updateWorkspace(workspace); - - expect(ws.getById("foobar").description).toBe("Foobar description"); - }); - - it("can add workspaces", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "123", - name: "foobar", - })); - - const workspace = ws.getById("123"); - - expect(workspace.name).toBe("foobar"); - expect(workspace.enabled).toBe(true); - }); - - it("cannot set a non-existent workspace to be active", () => { - const ws = WorkspaceStore.getInstance(); - - expect(() => ws.setActive("abc")).toThrow("doesn't exist"); - }); - - it("can set a existent workspace to be active", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "abc", - name: "foobar", - })); - - expect(() => ws.setActive("abc")).not.toThrowError(); - }); - - it("can remove a workspace", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "123", - name: "foobar", - })); - ws.addWorkspace(new Workspace({ - id: "1234", - name: "foobar 1", - })); - ws.removeWorkspaceById("123"); - - expect(ws.workspaces.size).toBe(2); - }); - - it("cannot create workspace with existent name", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "someid", - name: "default", - })); - - expect(ws.workspacesList.length).toBe(1); // default workspace only - }); - - it("cannot create workspace with empty name", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "random", - name: "", - })); - - expect(ws.workspacesList.length).toBe(1); // default workspace only - }); - - it("cannot create workspace with ' ' name", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "random", - name: " ", - })); - - expect(ws.workspacesList.length).toBe(1); // default workspace only - }); - - it("trim workspace name", () => { - const ws = WorkspaceStore.getInstance(); - - ws.addWorkspace(new Workspace({ - id: "random", - name: "default ", - })); - - expect(ws.workspacesList.length).toBe(1); // default workspace only - }); - }); - - describe("for a non-empty config", () => { - beforeEach(async () => { - WorkspaceStore.resetInstance(); - mockFs({ - tmp: { - "lens-workspace-store.json": JSON.stringify({ - currentWorkspace: "abc", - workspaces: [{ - id: "abc", - name: "test" - }, { - id: "default", - name: "default" - }] - }) - } - }); - - await WorkspaceStore.getInstance().load(); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("doesn't revert to default workspace", async () => { - const ws = WorkspaceStore.getInstance(); - - expect(ws.currentWorkspaceId).toBe("abc"); - }); - }); -}); diff --git a/src/common/catalog-category-registry.ts b/src/common/catalog-category-registry.ts new file mode 100644 index 0000000000..d8d65564de --- /dev/null +++ b/src/common/catalog-category-registry.ts @@ -0,0 +1,56 @@ +import { action, computed, observable, toJS } from "mobx"; +import { CatalogCategory, CatalogEntityData } from "./catalog-entity"; + +export class CatalogCategoryRegistry { + @observable protected categories: CatalogCategory[] = []; + + @action add(category: CatalogCategory) { + this.categories.push(category); + } + + @action remove(category: CatalogCategory) { + this.categories = this.categories.filter((cat) => cat.apiVersion !== category.apiVersion && cat.kind !== category.kind); + } + + @computed get items() { + return toJS(this.categories); + } + + getForGroupKind(group: string, kind: string) { + return this.categories.find((c) => c.spec.group === group && c.spec.names.kind === kind) as T; + } + + getEntityForData(data: CatalogEntityData) { + const category = this.getCategoryForEntity(data); + + if (!category) { + return null; + } + + 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); + } + + getCategoryForEntity(data: CatalogEntityData) { + const splitApiVersion = data.apiVersion.split("/"); + const group = splitApiVersion[0]; + + const category = this.categories.find((category) => { + return category.spec.group === group && category.spec.names.kind === data.kind; + }); + + if (!category) return null; + + return category as T; + } +} + +export const catalogCategoryRegistry = new CatalogCategoryRegistry(); diff --git a/src/common/catalog-entities/index.ts b/src/common/catalog-entities/index.ts new file mode 100644 index 0000000000..a42e68606d --- /dev/null +++ b/src/common/catalog-entities/index.ts @@ -0,0 +1,2 @@ +export * from "./kubernetes-cluster"; +export * from "./web-link"; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts new file mode 100644 index 0000000000..78b99d3f5c --- /dev/null +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -0,0 +1,105 @@ +import { EventEmitter } from "events"; +import { observable } from "mobx"; +import { catalogCategoryRegistry } from "../catalog-category-registry"; +import { CatalogCategory, CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity"; +import { clusterDisconnectHandler } from "../cluster-ipc"; +import { clusterStore } from "../cluster-store"; +import { requestMain } from "../ipc"; + +export type KubernetesClusterSpec = { + kubeconfigPath: string; + kubeconfigContext: string; +}; + +export interface KubernetesClusterStatus extends CatalogEntityStatus { + phase: "connected" | "disconnected"; +} + +export class KubernetesCluster implements CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "KubernetesCluster"; + @observable public metadata: CatalogEntityMetadata; + @observable public status: KubernetesClusterStatus; + @observable public spec: KubernetesClusterSpec; + + constructor(data: CatalogEntityData) { + this.metadata = data.metadata; + this.status = data.status as KubernetesClusterStatus; + this.spec = data.spec as KubernetesClusterSpec; + } + + getId() { + return this.metadata.uid; + } + + getName() { + return this.metadata.name; + } + + async onRun(context: CatalogEntityActionContext) { + context.navigate(`/cluster/${this.metadata.uid}`); + } + + async onDetailsOpen() { + // + } + + async onContextMenuOpen(context: CatalogEntityContextMenuContext) { + context.menuItems = [ + { + icon: "settings", + title: "Settings", + onClick: async () => context.navigate(`/cluster/${this.metadata.uid}/settings`) + }, + { + icon: "delete", + title: "Delete", + onClick: async () => clusterStore.removeById(this.metadata.uid), + confirm: { + message: `Remove Kubernetes Cluster "${this.metadata.name} from Lens?` + } + }, + ]; + + if (this.status.active) { + context.menuItems.unshift({ + icon: "link_off", + title: "Disconnect", + onClick: async () => { + clusterStore.deactivate(this.metadata.uid); + requestMain(clusterDisconnectHandler, this.metadata.uid); + } + }); + } + + const category = catalogCategoryRegistry.getCategoryForEntity(this); + + if (category) category.emit("contextMenuOpen", this, context); + } +} + +export class KubernetesClusterCategory extends EventEmitter implements CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Kubernetes Clusters" + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: KubernetesCluster + } + ], + names: { + kind: "KubernetesCluster" + } + }; + + getId() { + return `${this.spec.group}/${this.spec.names.kind}`; + } +} + +catalogCategoryRegistry.add(new KubernetesClusterCategory()); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts new file mode 100644 index 0000000000..a8523408a3 --- /dev/null +++ b/src/common/catalog-entities/web-link.ts @@ -0,0 +1,65 @@ +import { observable } from "mobx"; +import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity"; +import { catalogCategoryRegistry } from "../catalog-category-registry"; + +export interface WebLinkStatus extends CatalogEntityStatus { + phase: "valid" | "invalid"; +} + +export type WebLinkSpec = { + url: string; +}; + +export class WebLink implements CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "KubernetesCluster"; + @observable public metadata: CatalogEntityMetadata; + @observable public status: WebLinkStatus; + @observable public spec: WebLinkSpec; + + getId() { + return this.metadata.uid; + } + + getName() { + return this.metadata.name; + } + + async onRun() { + window.open(this.spec.url, "_blank"); + } + + async onDetailsOpen() { + // + } + + async onContextMenuOpen() { + // + } +} + +export class WebLinkCategory implements CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Web Links" + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: WebLink + } + ], + names: { + kind: "WebLink" + } + }; + + getId() { + return `${this.spec.group}/${this.spec.names.kind}`; + } +} + +catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/catalog-entity-registry.ts b/src/common/catalog-entity-registry.ts new file mode 100644 index 0000000000..e800fa88f9 --- /dev/null +++ b/src/common/catalog-entity-registry.ts @@ -0,0 +1,32 @@ +import { action, computed, observable } from "mobx"; +import { CatalogEntity } from "./catalog-entity"; + +export class CatalogEntityRegistry { + protected sources = observable.map([], { deep: true }); + + @action addSource(id: string, source: CatalogEntity[]) { + this.sources.set(id, source); + } + + @action removeSource(id: string) { + this.sources.delete(id); + } + + @computed get items() { + const catalogItems: CatalogEntity[] = []; + + for (const items of this.sources.values()) { + items.forEach((item) => catalogItems.push(item)); + } + + return catalogItems; + } + + getItemsForApiKind(apiVersion: string, kind: string): T[] { + const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); + + return items as T[]; + } +} + +export const catalogEntityRegistry = new CatalogEntityRegistry(); diff --git a/src/common/catalog-entity.ts b/src/common/catalog-entity.ts new file mode 100644 index 0000000000..8a00297d3c --- /dev/null +++ b/src/common/catalog-entity.ts @@ -0,0 +1,75 @@ +export interface CatalogCategoryVersion { + name: string; + entityClass: { new(data: CatalogEntityData): CatalogEntity }; +} + +export interface CatalogCategory { + apiVersion: string; + kind: string; + metadata: { + name: string; + } + spec: { + group: string; + versions: CatalogCategoryVersion[]; + names: { + kind: string; + } + } + getId: () => string; +} + +export type CatalogEntityMetadata = { + uid: string; + name: string; + description?: string; + source?: string; + labels: { + [key: string]: string; + } + [key: string]: string | object; +}; + +export type CatalogEntityStatus = { + phase: string; + reason?: string; + message?: string; + active?: boolean; +}; + +export interface CatalogEntityActionContext { + navigate: (url: string) => void; + setCommandPaletteContext: (context?: CatalogEntity) => void; +} + +export type CatalogEntityContextMenu = { + icon: string; + title: string; + onClick: () => Promise; + confirm?: { + message: string; + } +}; + +export interface CatalogEntityContextMenuContext { + navigate: (url: string) => void; + menuItems: CatalogEntityContextMenu[]; +} + +export type CatalogEntityData = { + apiVersion: string; + kind: string; + metadata: CatalogEntityMetadata; + status: CatalogEntityStatus; + spec: { + [key: string]: any; + } +}; + +export interface CatalogEntity extends CatalogEntityData { + getId: () => string; + getName: () => string; + onRun: (context: CatalogEntityActionContext) => Promise; + onDetailsOpen: (context: CatalogEntityActionContext) => Promise; + onContextMenuOpen: (context: CatalogEntityContextMenuContext) => Promise; +} diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 4ac0ad9bdf..d1f11d8372 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,4 +1,3 @@ -import { workspaceStore } from "./workspace-store"; import path from "path"; import { app, ipcRenderer, remote, webFrame } from "electron"; import { unlink } from "fs-extra"; @@ -12,9 +11,6 @@ import { dumpConfigYaml } from "./kube-helpers"; import { saveToAppFiles } from "./utils/saveToAppFiles"; import { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; -import _ from "lodash"; -import move from "array-move"; -import type { WorkspaceId } from "./workspace-store"; import { ResourceType } from "../renderer/components/+cluster-settings/components/cluster-metrics-setting"; export interface ClusterIconUpload { @@ -47,8 +43,12 @@ export interface ClusterModel { /** Path to cluster kubeconfig */ kubeConfigPath: string; - /** Workspace id */ - workspace?: WorkspaceId; + /** + * Workspace id + * + * @deprecated + */ + workspace?: string; /** User context in kubeconfig */ contextName?: string; @@ -226,7 +226,6 @@ export class ClusterStore extends BaseStore { } this.activeCluster = clusterId; - workspaceStore.setLastActiveClusterId(clusterId); } deactivate(id: ClusterId) { @@ -235,22 +234,6 @@ export class ClusterStore extends BaseStore { } } - @action - swapIconOrders(workspace: WorkspaceId, from: number, to: number) { - const clusters = this.getByWorkspaceId(workspace); - - if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) { - throw new Error(`invalid from<->to arguments`); - } - - move.mutate(clusters, from, to); - - for (const i in clusters) { - // This resets the iconOrder to the current display order - clusters[i].preferences.iconOrder = +i; - } - } - hasClusters() { return this.clusters.size > 0; } @@ -259,13 +242,6 @@ export class ClusterStore extends BaseStore { return this.clusters.get(id) ?? null; } - getByWorkspaceId(workspaceId: string): Cluster[] { - const clusters = Array.from(this.clusters.values()) - .filter(cluster => cluster.workspace === workspaceId); - - return _.sortBy(clusters, cluster => cluster.preferences.iconOrder); - } - @action addClusters(...models: ClusterModel[]): Cluster[] { const clusters: Cluster[] = []; @@ -317,13 +293,6 @@ export class ClusterStore extends BaseStore { } } - @action - removeByWorkspaceId(workspaceId: string) { - this.getByWorkspaceId(workspaceId).forEach(cluster => { - this.removeById(cluster.id); - }); - } - @action protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) { const currentClusters = this.clusters.toJS(); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts new file mode 100644 index 0000000000..42cdc0a8b1 --- /dev/null +++ b/src/common/hotbar-store.ts @@ -0,0 +1,67 @@ +import { action, comparer, observable, toJS } from "mobx"; +import { BaseStore } from "./base-store"; +import migrations from "../migrations/hotbar-store"; + +export interface HotbarItem { + entity: { + uid: string; + }; + params?: { + [key: string]: string; + } +} + +export interface Hotbar { + name: string; + items: HotbarItem[]; +} + +export interface HotbarStoreModel { + hotbars: Hotbar[]; +} + +export class HotbarStore extends BaseStore { + @observable hotbars: Hotbar[] = []; + + private constructor() { + super({ + configName: "lens-hotbar-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + migrations, + }); + } + + @action protected async fromStore(data: Partial = {}) { + this.hotbars = data.hotbars || [{ + name: "default", + items: [] + }]; + } + + getByName(name: string) { + return this.hotbars.find((hotbar) => hotbar.name === name); + } + + add(hotbar: Hotbar) { + this.hotbars.push(hotbar); + } + + remove(hotbar: Hotbar) { + this.hotbars = this.hotbars.filter((h) => h !== hotbar); + } + + toJSON(): HotbarStoreModel { + const model: HotbarStoreModel = { + hotbars: this.hotbars + }; + + return toJS(model, { + recurseEverything: true, + }); + } +} + +export const hotbarStore = HotbarStore.getInstance(); diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts deleted file mode 100644 index b037124721..0000000000 --- a/src/common/workspace-store.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { ipcRenderer } from "electron"; -import { action, computed, observable, toJS, reaction } from "mobx"; -import { BaseStore } from "./base-store"; -import { clusterStore } from "./cluster-store"; -import { appEventBus } from "./event-bus"; -import { broadcastMessage, handleRequest, requestMain } from "../common/ipc"; -import logger from "../main/logger"; -import type { ClusterId } from "./cluster-store"; - -export type WorkspaceId = string; - -export interface WorkspaceStoreModel { - workspaces: WorkspaceModel[]; - currentWorkspace?: WorkspaceId; -} - -export interface WorkspaceModel { - id: WorkspaceId; - name: string; - description?: string; - ownerRef?: string; - lastActiveClusterId?: ClusterId; -} - -export interface WorkspaceState { - enabled: boolean; -} - -const updateFromModel = Symbol("updateFromModel"); - -/** - * Workspace - * - * @beta - */ -export class Workspace implements WorkspaceModel, WorkspaceState { - /** - * Unique id for workspace - * - * @observable - */ - @observable id: WorkspaceId; - /** - * Workspace name - * - * @observable - */ - @observable name: string; - /** - * Workspace description - * - * @observable - */ - @observable description?: string; - /** - * Workspace owner reference - * - * If extension sets ownerRef then it needs to explicitly mark workspace as enabled onActivate (or when workspace is saved) - * - * @observable - */ - @observable ownerRef?: string; - - /** - * Last active cluster id - * - * @observable - */ - @observable lastActiveClusterId?: ClusterId; - - - @observable private _enabled: boolean; - - constructor(data: WorkspaceModel) { - Object.assign(this, data); - - if (!ipcRenderer) { - reaction(() => this.getState(), () => { - this.pushState(); - }); - } - } - - /** - * Is workspace enabled - * - * Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace. - * - * @observable - */ - get enabled(): boolean { - return !this.isManaged || this._enabled; - } - - set enabled(enabled: boolean) { - this._enabled = enabled; - } - - /** - * Is workspace managed by an extension - */ - get isManaged(): boolean { - return !!this.ownerRef; - } - - /** - * Get workspace state - * - */ - getState(): WorkspaceState { - return toJS({ - enabled: this.enabled - }); - } - - /** - * Push state - * - * @internal - * @param state workspace state - */ - pushState(state = this.getState()) { - logger.silly("[WORKSPACE] pushing state", {...state, id: this.id}); - broadcastMessage("workspace:state", this.id, toJS(state)); - } - - /** - * - * @param state workspace state - */ - @action setState(state: WorkspaceState) { - Object.assign(this, state); - } - - [updateFromModel] = action((model: WorkspaceModel) => { - Object.assign(this, model); - }); - - toJSON(): WorkspaceModel { - return toJS({ - id: this.id, - name: this.name, - description: this.description, - ownerRef: this.ownerRef, - lastActiveClusterId: this.lastActiveClusterId - }); - } -} - -export class WorkspaceStore extends BaseStore { - static readonly defaultId: WorkspaceId = "default"; - private static stateRequestChannel = "workspace:states"; - - @observable currentWorkspaceId = WorkspaceStore.defaultId; - @observable workspaces = observable.map(); - - private constructor() { - super({ - configName: "lens-workspace-store", - }); - - this.workspaces.set(WorkspaceStore.defaultId, new Workspace({ - id: WorkspaceStore.defaultId, - name: "default" - })); - } - - async load() { - await super.load(); - type workspaceStateSync = { - id: string; - state: WorkspaceState; - }; - - if (ipcRenderer) { - logger.info("[WORKSPACE-STORE] requesting initial state sync"); - const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel); - - workspaceStates.forEach((workspaceState) => { - const workspace = this.getById(workspaceState.id); - - if (workspace) { - workspace.setState(workspaceState.state); - } - }); - } else { - handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => { - const states: workspaceStateSync[] = []; - - this.workspacesList.forEach((workspace) => { - states.push({ - state: workspace.getState(), - id: workspace.id - }); - }); - - return states; - }); - } - } - - registerIpcListener() { - logger.info("[WORKSPACE-STORE] starting to listen state events"); - ipcRenderer.on("workspace:state", (event, workspaceId: string, state: WorkspaceState) => { - this.getById(workspaceId)?.setState(state); - }); - } - - unregisterIpcListener() { - super.unregisterIpcListener(); - ipcRenderer.removeAllListeners("workspace:state"); - } - - @computed get currentWorkspace(): Workspace { - return this.getById(this.currentWorkspaceId); - } - - @computed get workspacesList() { - return Array.from(this.workspaces.values()); - } - - @computed get enabledWorkspacesList() { - return this.workspacesList.filter((w) => w.enabled); - } - - pushState() { - this.workspaces.forEach((w) => { - w.pushState(); - }); - } - - isDefault(id: WorkspaceId) { - return id === WorkspaceStore.defaultId; - } - - getById(id: WorkspaceId): Workspace { - return this.workspaces.get(id); - } - - getByName(name: string): Workspace { - return this.workspacesList.find(workspace => workspace.name === name); - } - - @action - setActive(id = WorkspaceStore.defaultId) { - if (id === this.currentWorkspaceId) return; - - if (!this.getById(id)) { - throw new Error(`workspace ${id} doesn't exist`); - } - this.currentWorkspaceId = id; - } - - @action - addWorkspace(workspace: Workspace) { - const { id, name } = workspace; - - if (!name.trim() || this.getByName(name.trim())) { - return; - } - this.workspaces.set(id, workspace); - - if (!workspace.isManaged) { - workspace.enabled = true; - } - - appEventBus.emit({name: "workspace", action: "add"}); - - return workspace; - } - - @action - updateWorkspace(workspace: Workspace) { - this.workspaces.set(workspace.id, workspace); - appEventBus.emit({name: "workspace", action: "update"}); - } - - @action - removeWorkspace(workspace: Workspace) { - this.removeWorkspaceById(workspace.id); - } - - @action - removeWorkspaceById(id: WorkspaceId) { - const workspace = this.getById(id); - - if (!workspace) return; - - if (this.isDefault(id)) { - throw new Error("Cannot remove default workspace"); - } - - if (this.currentWorkspaceId === id) { - this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default - } - this.workspaces.delete(id); - appEventBus.emit({name: "workspace", action: "remove"}); - clusterStore.removeByWorkspaceId(id); - } - - @action - setLastActiveClusterId(clusterId?: ClusterId, workspaceId = this.currentWorkspaceId) { - this.getById(workspaceId).lastActiveClusterId = clusterId; - } - - @action - protected fromStore({ currentWorkspace, workspaces = [] }: WorkspaceStoreModel) { - if (currentWorkspace) { - this.currentWorkspaceId = currentWorkspace; - } - - const currentWorkspaces = this.workspaces.toJS(); - const newWorkspaceIds = new Set([WorkspaceStore.defaultId]); // never delete default - - for (const workspaceModel of workspaces) { - const oldWorkspace = this.workspaces.get(workspaceModel.id); - - if (oldWorkspace) { - oldWorkspace[updateFromModel](workspaceModel); - } else { - this.workspaces.set(workspaceModel.id, new Workspace(workspaceModel)); - } - - newWorkspaceIds.add(workspaceModel.id); - } - - // remove deleted workspaces - for (const workspaceId of currentWorkspaces.keys()) { - if (!newWorkspaceIds.has(workspaceId)) { - this.workspaces.delete(workspaceId); - } - } - } - - toJSON(): WorkspaceStoreModel { - return toJS({ - currentWorkspace: this.currentWorkspaceId, - workspaces: this.workspacesList.map((w) => w.toJSON()), - }, { - recurseEverything: true - }); - } -} - -export const workspaceStore = WorkspaceStore.getInstance(); diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index 36a2f0bfb8..ad3a258b0e 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -3,11 +3,12 @@ import path from "path"; import hb from "handlebars"; import { observable } from "mobx"; import { ResourceApplier } from "../main/resource-applier"; -import { Cluster } from "../main/cluster"; +import { KubernetesCluster } from "./core-api/stores"; import logger from "../main/logger"; import { app } from "electron"; import { requestMain } from "../common/ipc"; import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc"; +import { clusterStore } from "../common/cluster-store"; export interface ClusterFeatureStatus { /** feature's current version, as set by the implementation */ @@ -44,7 +45,7 @@ export abstract class ClusterFeature { * * @param cluster the cluster that the feature is to be installed on */ - abstract async install(cluster: Cluster): Promise; + abstract install(cluster: KubernetesCluster): Promise; /** * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be upgraded. The implementation @@ -52,7 +53,7 @@ export abstract class ClusterFeature { * * @param cluster the cluster that the feature is to be upgraded on */ - abstract async upgrade(cluster: Cluster): Promise; + abstract upgrade(cluster: KubernetesCluster): Promise; /** * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation @@ -60,7 +61,7 @@ export abstract class ClusterFeature { * * @param cluster the cluster that the feature is to be uninstalled from */ - abstract async uninstall(cluster: Cluster): Promise; + abstract uninstall(cluster: KubernetesCluster): Promise; /** * to be implemented in the derived class, this method is called periodically by Lens to determine details about the feature's current status. The implementation @@ -72,7 +73,7 @@ export abstract class ClusterFeature { * * @return a promise, resolved with the updated ClusterFeatureStatus */ - abstract async updateStatus(cluster: Cluster): Promise; + abstract updateStatus(cluster: KubernetesCluster): Promise; /** * this is a helper method that conveniently applies kubernetes resources to the cluster. @@ -82,9 +83,15 @@ export abstract class ClusterFeature { * files are templated, the template parameters are filled using the templateContext field (See renderTemplate() method). Finally the resources are applied to the * cluster. As a string[] type resourceSpec is treated as an array of fully formed (not templated) kubernetes resources that are applied to the cluster */ - protected async applyResources(cluster: Cluster, resourceSpec: string | string[]) { + protected async applyResources(cluster: KubernetesCluster, resourceSpec: string | string[]) { let resources: string[]; + const clusterModel = clusterStore.getById(cluster.metadata.uid); + + if (!clusterModel) { + throw new Error(`cluster not found`); + } + if ( typeof resourceSpec === "string" ) { resources = this.renderTemplates(resourceSpec); } else { @@ -92,9 +99,9 @@ export abstract class ClusterFeature { } if (app) { - await new ResourceApplier(cluster).kubectlApplyAll(resources); + await new ResourceApplier(clusterModel).kubectlApplyAll(resources); } else { - await requestMain(clusterKubectlApplyAllHandler, cluster.id, resources); + await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources); } } diff --git a/src/extensions/core-api/catalog.ts b/src/extensions/core-api/catalog.ts new file mode 100644 index 0000000000..a1dfcb41d0 --- /dev/null +++ b/src/extensions/core-api/catalog.ts @@ -0,0 +1,12 @@ + +import { computed } from "mobx"; +import { CatalogEntity } from "../../common/catalog-entity"; +import { catalogEntityRegistry as registry } from "../../common/catalog-entity-registry"; + +export class CatalogEntityRegistry { + @computed getItemsForApiKind(apiVersion: string, kind: string): T[] { + return registry.getItemsForApiKind(apiVersion, kind); + } +} + +export const catalogEntities = new CatalogEntityRegistry(); diff --git a/src/extensions/core-api/stores.ts b/src/extensions/core-api/stores.ts index 706c336fdf..cd01d6ae93 100644 --- a/src/extensions/core-api/stores.ts +++ b/src/extensions/core-api/stores.ts @@ -1,8 +1,4 @@ export { ExtensionStore } from "../extension-store"; - -export { clusterStore, Cluster, ClusterStore } from "../stores/cluster-store"; -export type { ClusterModel, ClusterId } from "../stores/cluster-store"; - -export { workspaceStore, Workspace, WorkspaceStore } from "../stores/workspace-store"; -export type { WorkspaceId, WorkspaceModel } from "../stores/workspace-store"; - +export { KubernetesCluster, KubernetesClusterCategory } from "../../common/catalog-entities/kubernetes-cluster"; +export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog-category-registry"; +export { catalogEntities } from "./catalog"; diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 3af9ad874e..d36123b95a 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -213,7 +213,6 @@ export class ExtensionLoader { registries.globalPageRegistry.add(extension.globalPages, extension), registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension), registries.appPreferenceRegistry.add(extension.appPreferences), - registries.clusterFeatureRegistry.add(extension.clusterFeatures), registries.statusBarRegistry.add(extension.statusBarItems), registries.commandRegistry.add(extension.commands), ]; diff --git a/src/extensions/interfaces/catalog.ts b/src/extensions/interfaces/catalog.ts new file mode 100644 index 0000000000..916742076b --- /dev/null +++ b/src/extensions/interfaces/catalog.ts @@ -0,0 +1 @@ +export * from "../../common/catalog-entity"; diff --git a/src/extensions/interfaces/index.ts b/src/extensions/interfaces/index.ts index 7b1c601537..8bfca77884 100644 --- a/src/extensions/interfaces/index.ts +++ b/src/extensions/interfaces/index.ts @@ -1 +1,2 @@ export * from "./registrations"; +export * from "./catalog"; diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index 10a55d1b78..6af60d97d9 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -1,5 +1,4 @@ export type { AppPreferenceRegistration, AppPreferenceComponents } from "../registries/app-preference-registry"; -export type { ClusterFeatureRegistration, ClusterFeatureComponents } from "../registries/cluster-feature-registry"; export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry"; export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry"; export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index f0e943540d..1cfd3105b0 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -2,6 +2,8 @@ import type { MenuRegistration } from "./registries/menu-registry"; import { LensExtension } from "./lens-extension"; import { WindowManager } from "../main/window-manager"; import { getExtensionPageUrl } from "./registries/page-registry"; +import { catalogEntityRegistry } from "../common/catalog-entity-registry"; +import { CatalogEntity } from "../common/catalog-entity"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; @@ -16,4 +18,12 @@ export class LensMainExtension extends LensExtension { await windowManager.navigate(pageUrl, frameId); } + + addCatalogSource(id: string, source: CatalogEntity[]) { + catalogEntityRegistry.addSource(`${this.name}:${id}`, source); + } + + removeCatalogSource(id: string) { + catalogEntityRegistry.removeSource(`${this.name}:${id}`); + } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 982830d8af..bf1f8cbeb3 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -1,4 +1,4 @@ -import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"; +import type { AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"; import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; @@ -11,7 +11,6 @@ export class LensRendererExtension extends LensExtension { clusterPageMenus: ClusterPageMenuRegistration[] = []; kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; appPreferences: AppPreferenceRegistration[] = []; - clusterFeatures: ClusterFeatureRegistration[] = []; statusBarItems: StatusBarRegistration[] = []; kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; diff --git a/src/extensions/registries/cluster-feature-registry.ts b/src/extensions/registries/cluster-feature-registry.ts deleted file mode 100644 index 5017ad27a6..0000000000 --- a/src/extensions/registries/cluster-feature-registry.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type React from "react"; -import { BaseRegistry } from "./base-registry"; -import { ClusterFeature } from "../cluster-feature"; - -export interface ClusterFeatureComponents { - Description: React.ComponentType; -} - -export interface ClusterFeatureRegistration { - title: string; - components: ClusterFeatureComponents - feature: ClusterFeature -} - -export class ClusterFeatureRegistry extends BaseRegistry { -} - -export const clusterFeatureRegistry = new ClusterFeatureRegistry(); diff --git a/src/extensions/registries/command-registry.ts b/src/extensions/registries/command-registry.ts index 0b1fc0252c..45af9121a1 100644 --- a/src/extensions/registries/command-registry.ts +++ b/src/extensions/registries/command-registry.ts @@ -1,25 +1,25 @@ // Extensions API -> Commands -import type { Cluster } from "../../main/cluster"; -import type { Workspace } from "../../common/workspace-store"; import { BaseRegistry } from "./base-registry"; -import { action } from "mobx"; +import { action, observable } from "mobx"; import { LensExtension } from "../lens-extension"; +import { CatalogEntity } from "../../common/catalog-entity"; export type CommandContext = { - cluster?: Cluster; - workspace?: Workspace; + entity?: CatalogEntity; }; export interface CommandRegistration { id: string; title: string; - scope: "cluster" | "global"; + scope: "entity" | "global"; action: (context: CommandContext) => void; isActive?: (context: CommandContext) => boolean; } export class CommandRegistry extends BaseRegistry { + @observable activeEntity: CatalogEntity; + @action add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) { const itemArray = [items].flat(); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index b6838f1bab..9f0aba5f0d 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -7,6 +7,5 @@ export * from "./app-preference-registry"; export * from "./status-bar-registry"; export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; -export * from "./cluster-feature-registry"; export * from "./kube-object-status-registry"; export * from "./command-registry"; diff --git a/src/extensions/stores/cluster-store.ts b/src/extensions/stores/cluster-store.ts deleted file mode 100644 index c1a18c453b..0000000000 --- a/src/extensions/stores/cluster-store.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { clusterStore as internalClusterStore, ClusterId } from "../../common/cluster-store"; -import type { ClusterModel } from "../../common/cluster-store"; -import { Cluster } from "../../main/cluster"; -import { Singleton } from "../core-api/utils"; -import { ObservableMap } from "mobx"; - -export { Cluster } from "../../main/cluster"; -export type { ClusterModel, ClusterId } from "../../common/cluster-store"; - -/** - * Store for all added clusters - * - * @beta - */ -export class ClusterStore extends Singleton { - - /** - * Active cluster id - */ - get activeClusterId(): string { - return internalClusterStore.activeCluster; - } - - /** - * Set active cluster id - */ - set activeClusterId(id : ClusterId) { - internalClusterStore.setActive(id); - } - - /** - * Map of all clusters - */ - get clusters(): ObservableMap { - return internalClusterStore.clusters; - } - - /** - * Get active cluster (a cluster which is currently visible) - */ - get activeCluster(): Cluster | null { - return internalClusterStore.active; - } - - /** - * Array of all clusters - */ - get clustersList(): Cluster[] { - return internalClusterStore.clustersList; - } - - /** - * Array of all enabled clusters - */ - get enabledClustersList(): Cluster[] { - return internalClusterStore.enabledClustersList; - } - - /** - * Array of all clusters that have active connection to a Kubernetes cluster - */ - get connectedClustersList(): Cluster[] { - return internalClusterStore.connectedClustersList; - } - - /** - * Get cluster object by cluster id - * @param id cluster id - */ - getById(id: ClusterId): Cluster { - return internalClusterStore.getById(id); - } - - /** - * Get all clusters belonging to a workspace - * @param workspaceId workspace id - */ - getByWorkspaceId(workspaceId: string): Cluster[] { - return internalClusterStore.getByWorkspaceId(workspaceId); - } - - /** - * Add clusters to store - * @param models list of cluster models - */ - addClusters(...models: ClusterModel[]): Cluster[] { - return internalClusterStore.addClusters(...models); - } - - /** - * Add a cluster to store - * @param model cluster - */ - addCluster(model: ClusterModel | Cluster): Cluster { - return internalClusterStore.addCluster(model); - } - - /** - * Remove a cluster from store - * @param model cluster - */ - async removeCluster(model: ClusterModel) { - return internalClusterStore.removeById(model.id); - } - - /** - * Remove a cluster from store by id - * @param clusterId cluster id - */ - async removeById(clusterId: ClusterId) { - return internalClusterStore.removeById(clusterId); - } - - /** - * Remove all clusters belonging to a workspaces - * @param workspaceId workspace id - */ - removeByWorkspaceId(workspaceId: string) { - return internalClusterStore.removeByWorkspaceId(workspaceId); - } -} - - -export const clusterStore = ClusterStore.getInstance(); diff --git a/src/extensions/stores/workspace-store.ts b/src/extensions/stores/workspace-store.ts deleted file mode 100644 index 2ff4a830fd..0000000000 --- a/src/extensions/stores/workspace-store.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Singleton } from "../core-api/utils"; -import { workspaceStore as internalWorkspaceStore, WorkspaceStore as InternalWorkspaceStore, Workspace, WorkspaceId } from "../../common/workspace-store"; -import { ObservableMap } from "mobx"; - -export { Workspace } from "../../common/workspace-store"; -export type { WorkspaceId, WorkspaceModel } from "../../common/workspace-store"; - -/** - * Stores all workspaces - * - * @beta - */ -export class WorkspaceStore extends Singleton { - /** - * Default workspace id, this workspace is always present - */ - static readonly defaultId: WorkspaceId = InternalWorkspaceStore.defaultId; - - /** - * Currently active workspace id - */ - get currentWorkspaceId(): string { - return internalWorkspaceStore.currentWorkspaceId; - } - - /** - * Set active workspace id - */ - set currentWorkspaceId(id: string) { - internalWorkspaceStore.currentWorkspaceId = id; - } - - /** - * Map of all workspaces - */ - get workspaces(): ObservableMap { - return internalWorkspaceStore.workspaces; - } - - /** - * Currently active workspace - */ - get currentWorkspace(): Workspace { - return internalWorkspaceStore.currentWorkspace; - } - - /** - * Array of all workspaces - */ - get workspacesList(): Workspace[] { - return internalWorkspaceStore.workspacesList; - } - - /** - * Array of all enabled (visible) workspaces - */ - get enabledWorkspacesList(): Workspace[] { - return internalWorkspaceStore.enabledWorkspacesList; - } - - /** - * Get workspace by id - * @param id workspace id - */ - getById(id: WorkspaceId): Workspace { - return internalWorkspaceStore.getById(id); - } - - /** - * Get workspace by name - * @param name workspace name - */ - getByName(name: string): Workspace { - return internalWorkspaceStore.getByName(name); - } - - /** - * Set active workspace - * @param id workspace id - */ - setActive(id = WorkspaceStore.defaultId) { - return internalWorkspaceStore.setActive(id); - } - - /** - * Add a workspace to store - * @param workspace workspace - */ - addWorkspace(workspace: Workspace) { - return internalWorkspaceStore.addWorkspace(workspace); - } - - /** - * Update a workspace in store - * @param workspace workspace - */ - updateWorkspace(workspace: Workspace) { - return internalWorkspaceStore.updateWorkspace(workspace); - } - - /** - * Remove workspace from store - * @param workspace workspace - */ - removeWorkspace(workspace: Workspace) { - return internalWorkspaceStore.removeWorkspace(workspace); - } - - /** - * Remove workspace by id - * @param id workspace - */ - removeWorkspaceById(id: WorkspaceId) { - return internalWorkspaceStore.removeWorkspaceById(id); - } -} - -export const workspaceStore = WorkspaceStore.getInstance(); diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 4b11a19879..113bb49a0c 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -31,7 +31,6 @@ jest.mock("request-promise-native"); import { Console } from "console"; import mockFs from "mock-fs"; -import { workspaceStore } from "../../common/workspace-store"; import { Cluster } from "../cluster"; import { ContextHandler } from "../context-handler"; import { getFreePort } from "../port"; @@ -81,8 +80,7 @@ describe("create clusters", () => { c = new Cluster({ id: "foo", contextName: "minikube", - kubeConfigPath: "minikube-config.yml", - workspace: workspaceStore.currentWorkspaceId + kubeConfigPath: "minikube-config.yml" }); }); @@ -162,8 +160,7 @@ describe("create clusters", () => { }({ id: "foo", contextName: "minikube", - kubeConfigPath: "minikube-config.yml", - workspace: workspaceStore.currentWorkspaceId + kubeConfigPath: "minikube-config.yml" }); await c.init(port); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index d04f7492f2..a272d1e597 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -26,7 +26,6 @@ jest.mock("winston", () => ({ import { KubeconfigManager } from "../kubeconfig-manager"; import mockFs from "mock-fs"; import { Cluster } from "../cluster"; -import { workspaceStore } from "../../common/workspace-store"; import { ContextHandler } from "../context-handler"; import { getFreePort } from "../port"; import fse from "fs-extra"; @@ -77,8 +76,7 @@ describe("kubeconfig manager tests", () => { const cluster = new Cluster({ id: "foo", contextName: "minikube", - kubeConfigPath: "minikube-config.yml", - workspace: workspaceStore.currentWorkspaceId + kubeConfigPath: "minikube-config.yml" }); const contextHandler = new ContextHandler(cluster); const port = await getFreePort(); @@ -98,8 +96,7 @@ describe("kubeconfig manager tests", () => { const cluster = new Cluster({ id: "foo", contextName: "minikube", - kubeConfigPath: "minikube-config.yml", - workspace: workspaceStore.currentWorkspaceId + kubeConfigPath: "minikube-config.yml" }); const contextHandler = new ContextHandler(cluster); const port = await getFreePort(); diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts new file mode 100644 index 0000000000..ffe8cc17d0 --- /dev/null +++ b/src/main/catalog-pusher.ts @@ -0,0 +1,32 @@ +import { autorun, toJS } from "mobx"; +import { broadcastMessage, subscribeToBroadcast, unsubscribeFromBroadcast } from "../common/ipc"; +import { CatalogEntityRegistry} from "../common/catalog-entity-registry"; +import "../common/catalog-entities/kubernetes-cluster"; + +export class CatalogPusher { + static init(catalog: CatalogEntityRegistry) { + new CatalogPusher(catalog).init(); + } + + private constructor(private catalog: CatalogEntityRegistry) {} + + init() { + const disposers: { (): void; }[] = []; + + disposers.push(autorun(() => { + this.broadcast(); + })); + + const listener = subscribeToBroadcast("catalog:broadcast", () => { + this.broadcast(); + }); + + disposers.push(() => unsubscribeFromBroadcast("catalog:broadcast", listener)); + + return disposers; + } + + broadcast() { + broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true })); + } +} diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index dfcda98203..c0ba24cc5d 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,16 +1,25 @@ import "../common/cluster-ipc"; import type http from "http"; import { ipcMain } from "electron"; -import { autorun, reaction } from "mobx"; +import { action, autorun, observable, reaction, toJS } from "mobx"; import { clusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; import { Singleton } from "../common/utils"; +import { CatalogEntity } from "../common/catalog-entity"; +import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster"; +import { catalogEntityRegistry } from "../common/catalog-entity-registry"; + +const clusterOwnerRef = "ClusterManager"; export class ClusterManager extends Singleton { + @observable.deep catalogSource: CatalogEntity[] = []; + constructor(public readonly port: number) { super(); + + catalogEntityRegistry.addSource("lens:kubernetes-clusters", this.catalogSource); // auto-init clusters reaction(() => clusterStore.enabledClustersList, (clusters) => { clusters.forEach((cluster) => { @@ -19,8 +28,18 @@ export class ClusterManager extends Singleton { cluster.init(port); } }); + }, { fireImmediately: true }); + reaction(() => toJS(clusterStore.enabledClustersList, { recurseEverything: true }), () => { + this.updateCatalogSource(clusterStore.enabledClustersList); + }, { fireImmediately: true }); + + reaction(() => catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { + this.syncClustersFromCatalog(entities); + }); + + // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(clusterStore.removedClusters.values()); @@ -40,6 +59,90 @@ export class ClusterManager extends Singleton { ipcMain.on("network:online", () => { this.onNetworkOnline(); }); } + @action protected updateCatalogSource(clusters: Cluster[]) { + this.catalogSource.forEach((entity, index) => { + const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id); + + if (clusterIndex === -1) { + this.catalogSource.splice(index, 1); + } + }); + + clusters.filter((c) => !c.ownerRef).forEach((cluster) => { + const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id); + const newEntity = this.catalogEntityFromCluster(cluster); + + if (entityIndex === -1) { + this.catalogSource.push(newEntity); + } else { + const oldEntity = this.catalogSource[entityIndex]; + + newEntity.status.phase = cluster.disconnected ? "disconnected" : "connected"; + newEntity.status.active = !cluster.disconnected; + newEntity.metadata.labels = { + ...newEntity.metadata.labels, + ...oldEntity.metadata.labels + }; + this.catalogSource.splice(entityIndex, 1, newEntity); + } + }); + } + + @action syncClustersFromCatalog(entities: KubernetesCluster[]) { + entities.filter((entity) => entity.metadata.source !== "local").forEach((entity: KubernetesCluster) => { + const cluster = clusterStore.getById(entity.metadata.uid); + + if (!cluster) { + clusterStore.addCluster({ + id: entity.metadata.uid, + enabled: true, + ownerRef: clusterOwnerRef, + preferences: { + clusterName: entity.metadata.name + }, + kubeConfigPath: entity.spec.kubeconfigPath, + contextName: entity.spec.kubeconfigContext + }); + } else { + cluster.enabled = true; + if (!cluster.ownerRef) cluster.ownerRef = clusterOwnerRef; + cluster.preferences.clusterName = entity.metadata.name; + cluster.kubeConfigPath = entity.spec.kubeconfigPath; + cluster.contextName = entity.spec.kubeconfigContext; + + entity.status = { + phase: cluster.disconnected ? "disconnected" : "connected", + active: !cluster.disconnected + }; + } + }); + } + + protected catalogEntityFromCluster(cluster: Cluster) { + return new KubernetesCluster(toJS({ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: cluster.id, + name: cluster.name, + source: "local", + labels: { + "distro": (cluster.metadata["distribution"] || "unknown").toString() + } + }, + spec: { + kubeconfigPath: cluster.kubeConfigPath, + kubeconfigContext: cluster.contextName + }, + status: { + phase: cluster.disconnected ? "disconnected" : "connected", + reason: "", + message: "", + active: !cluster.disconnected + } + })); + } + protected onNetworkOffline() { logger.info("[CLUSTER-MANAGER]: network is offline"); clusterStore.enabledClustersList.forEach((cluster) => { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index eee88cd6db..7a02c3e0c9 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,7 +1,6 @@ import { ipcMain } from "electron"; import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store"; import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; -import type { WorkspaceId } from "../common/workspace-store"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; import { broadcastMessage, InvalidKubeconfigChannel, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; @@ -104,18 +103,16 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable contextName: string; - /** - * Workspace id - * - * @observable - */ - @observable workspace: WorkspaceId; /** * Path to kubeconfig * * @observable */ @observable kubeConfigPath: string; + /** + * @deprecated + */ + @observable workspace: string; /** * Kubernetes API server URL * diff --git a/src/main/index.ts b/src/main/index.ts index 0a946b9c36..2ba462ce0c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,6 @@ import { registerFileProtocol } from "../common/register-protocol"; import logger from "./logger"; import { clusterStore } from "../common/cluster-store"; import { userStore } from "../common/user-store"; -import { workspaceStore } from "../common/workspace-store"; import { appEventBus } from "../common/event-bus"; import { extensionLoader } from "../extensions/extension-loader"; import { extensionsStore } from "../extensions/extensions-store"; @@ -30,6 +29,9 @@ import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { bindBroadcastHandlers } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; +import { CatalogPusher } from "./catalog-pusher"; +import { catalogEntityRegistry } from "../common/catalog-entity-registry"; +import { hotbarStore } from "../common/hotbar-store"; const workingDir = path.join(app.getPath("appData"), appName); let proxyPort: number; @@ -107,7 +109,7 @@ app.on("ready", async () => { await Promise.all([ userStore.load(), clusterStore.load(), - workspaceStore.load(), + hotbarStore.load(), extensionsStore.load(), filesystemProvisionerStore.load(), ]); @@ -164,6 +166,7 @@ app.on("ready", async () => { } ipcMain.on(IpcRendererNavigationEvents.LOADED, () => { + CatalogPusher.init(catalogEntityRegistry); startUpdateChecking(); LensProtocolRouterMain .getInstance() diff --git a/src/main/menu.ts b/src/main/menu.ts index 142c776878..1dcd320584 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -7,6 +7,7 @@ import { preferencesURL } from "../renderer/components/+preferences/preferences. import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route"; import { extensionsURL } from "../renderer/components/+extensions/extensions.route"; +import { catalogURL } from "../renderer/components/+catalog/catalog.route"; import { menuRegistry } from "../extensions/registries/menu-registry"; import logger from "./logger"; import { exitApp } from "./exit-app"; @@ -175,6 +176,13 @@ export function buildMenu(windowManager: WindowManager) { const viewMenu: MenuItemConstructorOptions = { label: "View", submenu: [ + { + label: "Catalog", + accelerator: "Shift+CmdOrCtrl+C", + click() { + navigate(catalogURL()); + } + }, { label: "Command Palette...", accelerator: "Shift+CmdOrCtrl+P", diff --git a/src/main/tray.ts b/src/main/tray.ts index b9174977ae..a15fa845d3 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -5,10 +5,7 @@ import { autorun } from "mobx"; import { showAbout } from "./menu"; import { checkForUpdates } from "./app-updater"; import { WindowManager } from "./window-manager"; -import { clusterStore } from "../common/cluster-store"; -import { workspaceStore } from "../common/workspace-store"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; -import { clusterViewURL } from "../renderer/components/cluster-manager/cluster-view.route"; import logger from "./logger"; import { isDevelopment, isWindows } from "../common/vars"; import { exitApp } from "./exit-app"; @@ -78,28 +75,6 @@ function createTrayMenu(windowManager: WindowManager): Menu { .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to Preferences`, { error })); }, }, - { - label: "Clusters", - submenu: workspaceStore.enabledWorkspacesList - .map(workspace => [workspace, clusterStore.getByWorkspaceId(workspace.id)] as const) - .map(([workspace, clusters]) => ({ - label: workspace.name, - toolTip: workspace.description, - enabled: clusters.length > 0, - submenu: clusters.map(({ id: clusterId, name: label, online, workspace }) => ({ - checked: online, - type: "checkbox", - label, - toolTip: clusterId, - click() { - workspaceStore.setActive(workspace); - windowManager - .navigate(clusterViewURL({ params: { clusterId } })) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to cluster`, { clusterId, error })); - } - })) - })), - }, { label: "Check for updates", click() { diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts new file mode 100644 index 0000000000..22087ab569 --- /dev/null +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -0,0 +1,28 @@ +// Cleans up a store that had the state related data stored +import { Hotbar } from "../../common/hotbar-store"; +import { clusterStore } from "../../common/cluster-store"; +import { migration } from "../migration-wrapper"; + +export default migration({ + version: "5.0.0-alpha.0", + run(store) { + const hotbars: Hotbar[] = []; + + clusterStore.enabledClustersList.forEach((cluster: any) => { + const name = cluster.workspace || "default"; + let hotbar = hotbars.find((h) => h.name === name); + + if (!hotbar) { + hotbar = { name, items: [] }; + hotbars.push(hotbar); + } + + hotbar.items.push({ + entity: { uid: cluster.id }, + params: {} + }); + }); + + store.set("hotbars", hotbars); + } +}); diff --git a/src/migrations/hotbar-store/index.ts b/src/migrations/hotbar-store/index.ts new file mode 100644 index 0000000000..ae9d4bc125 --- /dev/null +++ b/src/migrations/hotbar-store/index.ts @@ -0,0 +1,7 @@ +// Hotbar store migrations + +import version500alpha0 from "./5.0.0-alpha.0"; + +export default { + ...version500alpha0, +}; diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts new file mode 100644 index 0000000000..2b36086856 --- /dev/null +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -0,0 +1,135 @@ +import { CatalogEntityRegistry } from "../catalog-entity-registry"; +import "../../../common/catalog-entities"; +import { catalogCategoryRegistry } from "../../../common/catalog-category-registry"; + +describe("CatalogEntityRegistry", () => { + describe("updateItems", () => { + it("adds new catalog item", () => { + const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const items = [{ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: "123", + name: "foobar", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + }]; + + catalog.updateItems(items); + expect(catalog.items.length).toEqual(1); + + items.push({ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: "456", + name: "barbaz", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + }); + + catalog.updateItems(items); + expect(catalog.items.length).toEqual(2); + }); + + it("ignores unknown items", () => { + const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const items = [{ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "FooBar", + metadata: { + uid: "123", + name: "foobar", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + }]; + + catalog.updateItems(items); + expect(catalog.items.length).toEqual(0); + }); + + it("updates existing items", () => { + const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const items = [{ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: "123", + name: "foobar", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + }]; + + catalog.updateItems(items); + expect(catalog.items.length).toEqual(1); + expect(catalog.items[0].status.phase).toEqual("disconnected"); + + items[0].status.phase = "connected"; + + catalog.updateItems(items); + expect(catalog.items.length).toEqual(1); + expect(catalog.items[0].status.phase).toEqual("connected"); + }); + + it("removes deleted items", () => { + const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const items = [ + { + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: "123", + name: "foobar", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + }, + { + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: "456", + name: "barbaz", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + } + ]; + + catalog.updateItems(items); + items.splice(0, 1); + catalog.updateItems(items); + expect(catalog.items.length).toEqual(1); + expect(catalog.items[0].metadata.uid).toEqual("456"); + }); + }); +}); diff --git a/src/renderer/api/__tests__/pods.test.ts b/src/renderer/api/__tests__/pods.test.ts index 99cf95bf7f..9a257bb724 100644 --- a/src/renderer/api/__tests__/pods.test.ts +++ b/src/renderer/api/__tests__/pods.test.ts @@ -145,10 +145,10 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po describe("Pods", () => { const podTests = []; - for (let r = 0; r < 10; r += 1) { - for (let d = 0; d < 10; d += 1) { - for (let ir = 0; ir < 10; ir += 1) { - for (let id = 0; id < 10; id += 1) { + for (let r = 0; r < 3; r += 1) { + for (let d = 0; d < 3; d += 1) { + for (let ir = 0; ir < 3; ir += 1) { + for (let id = 0; id < 3; id += 1) { podTests.push([r, d, ir, id]); } } diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts new file mode 100644 index 0000000000..afed7a88cf --- /dev/null +++ b/src/renderer/api/catalog-entity-registry.ts @@ -0,0 +1,61 @@ +import { action, observable } from "mobx"; +import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc"; +import { CatalogCategory, CatalogEntity, CatalogEntityData } from "../../common/catalog-entity"; +import { catalogCategoryRegistry, CatalogCategoryRegistry } from "../../common/catalog-category-registry"; +import "../../common/catalog-entities"; + +export class CatalogEntityRegistry { + @observable protected _items: CatalogEntity[] = observable.array([], { deep: true }); + + constructor(private categoryRegistry: CatalogCategoryRegistry) {} + + init() { + subscribeToBroadcast("catalog:items", (ev, items: CatalogEntityData[]) => { + this.updateItems(items); + }); + broadcastMessage("catalog:broadcast"); + } + + @action updateItems(items: CatalogEntityData[]) { + this._items.forEach((item, index) => { + const foundIndex = items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid); + + if (foundIndex === -1) { + this._items.splice(index, 1); + } + }); + + items.forEach((data) => { + const item = this.categoryRegistry.getEntityForData(data); + + if (!item) return; // invalid data + + const index = this._items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid); + + if (index === -1) { + this._items.push(item); + } else { + this._items.splice(index, 1, item); + } + }); + } + + get items() { + return this._items; + } + + getItemsForApiKind(apiVersion: string, kind: string): T[] { + const items = this._items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); + + return items as T[]; + } + + getItemsForCategory(category: CatalogCategory): T[] { + const supportedVersions = category.spec.versions.map((v) => `${category.spec.group}/${v.name}`); + 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/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts new file mode 100644 index 0000000000..a8006be0f7 --- /dev/null +++ b/src/renderer/api/catalog-entity.ts @@ -0,0 +1,12 @@ +import { navigate } from "../navigation"; +import { commandRegistry } from "../../extensions/registries"; +import { CatalogEntity } from "../../common/catalog-entity"; + +export { CatalogEntity, CatalogEntityData, CatalogEntityActionContext, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../common/catalog-entity"; + +export const catalogEntityRunContext = { + navigate: (url: string) => navigate(url), + setCommandPaletteContext: (entity?: CatalogEntity) => { + commandRegistry.activeEntity = entity; + } +}; diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 448cd9da8f..98833e3d4d 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -62,7 +62,9 @@ export interface IKubeResourceList { } export interface IKubeApiCluster { - id: string; + metadata: { + uid: string; + } } export function forCluster(cluster: IKubeApiCluster, kubeClass: IKubeObjectConstructor): KubeApi { @@ -71,7 +73,7 @@ export function forCluster(cluster: IKubeApiCluster, kubeC debug: isDevelopment, }, { headers: { - "X-Cluster-ID": cluster.id + "X-Cluster-ID": cluster.metadata.uid } }); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 0d412e257c..c9ef607871 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -10,11 +10,11 @@ import { clusterStore } from "../common/cluster-store"; import { userStore } from "../common/user-store"; import { delay } from "../common/utils"; import { isMac, isDevelopment } from "../common/vars"; -import { workspaceStore } from "../common/workspace-store"; import * as LensExtensions from "../extensions/extension-api"; import { extensionDiscovery } from "../extensions/extension-discovery"; import { extensionLoader } from "../extensions/extension-loader"; import { extensionsStore } from "../extensions/extensions-store"; +import { hotbarStore } from "../common/hotbar-store"; import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; @@ -56,7 +56,7 @@ export async function bootstrap(App: AppComponent) { // preload common stores await Promise.all([ userStore.load(), - workspaceStore.load(), + hotbarStore.load(), clusterStore.load(), extensionsStore.load(), filesystemProvisionerStore.load(), @@ -65,7 +65,6 @@ export async function bootstrap(App: AppComponent) { // Register additional store listeners clusterStore.registerIpcListener(); - workspaceStore.registerIpcListener(); // init app's dependencies if any if (App.init) { @@ -74,7 +73,6 @@ export async function bootstrap(App: AppComponent) { window.addEventListener("message", (ev: MessageEvent) => { if (ev.data === "teardown") { userStore.unregisterIpcListener(); - workspaceStore.unregisterIpcListener(); clusterStore.unregisterIpcListener(); unmountComponentAtNode(rootElem); window.location.href = "about:blank"; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index dc5cdf4d4c..b9392e2b95 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -12,11 +12,9 @@ import { Button } from "../button"; import { Icon } from "../icon"; import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store"; -import { workspaceStore } from "../../../common/workspace-store"; import { v4 as uuid } from "uuid"; import { navigate } from "../../navigation"; import { userStore } from "../../../common/user-store"; -import { clusterViewURL } from "../cluster-manager/cluster-view.route"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Tab, Tabs } from "../tabs"; @@ -24,6 +22,7 @@ import { ExecValidationNotFoundError } from "../../../common/custom-errors"; import { appEventBus } from "../../../common/event-bus"; import { PageLayout } from "../layout/page-layout"; import { docsUrl } from "../../../common/vars"; +import { catalogURL } from "../+catalog"; enum KubeConfigSourceTab { FILE = "file", @@ -171,7 +170,6 @@ export class AddCluster extends React.Component { return { id: clusterId, kubeConfigPath, - workspace: workspaceStore.currentWorkspaceId, contextName: kubeConfig.currentContext, preferences: { clusterName: kubeConfig.currentContext, @@ -183,18 +181,11 @@ export class AddCluster extends React.Component { runInAction(() => { clusterStore.addClusters(...newClusters); - if (newClusters.length === 1) { - const clusterId = newClusters[0].id; + Notifications.ok( + <>Successfully imported {newClusters.length} cluster(s) + ); - clusterStore.setActive(clusterId); - navigate(clusterViewURL({ params: { clusterId } })); - } else { - if (newClusters.length > 1) { - Notifications.ok( - <>Successfully imported {newClusters.length} cluster(s) - ); - } - } + navigate(catalogURL()); }); this.refreshContexts(); } catch (err) { diff --git a/src/renderer/components/+apps/apps.command.ts b/src/renderer/components/+apps/apps.command.ts index ff6c9d615d..8c499e7e4e 100644 --- a/src/renderer/components/+apps/apps.command.ts +++ b/src/renderer/components/+apps/apps.command.ts @@ -6,13 +6,13 @@ import { releaseURL } from "../+apps-releases"; commandRegistry.add({ id: "cluster.viewHelmCharts", title: "Cluster: View Helm Charts", - scope: "cluster", + scope: "entity", action: () => navigate(helmChartsURL()) }); commandRegistry.add({ id: "cluster.viewHelmReleases", title: "Cluster: View Helm Releases", - scope: "cluster", + scope: "entity", action: () => navigate(releaseURL()) }); diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts new file mode 100644 index 0000000000..2c867ca02b --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -0,0 +1,81 @@ +import { action, computed, IReactionDisposer, observable, reaction } from "mobx"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity"; +import { ItemObject, ItemStore } from "../../item.store"; +import { autobind } from "../../utils"; +import { CatalogCategory } from "../../../common/catalog-entity"; + +export class CatalogEntityItem implements ItemObject { + constructor(public entity: CatalogEntity) {} + + get name() { + return this.entity.metadata.name; + } + + getName() { + return this.entity.metadata.name; + } + + get id() { + return this.entity.metadata.uid; + } + + getId() { + return this.id; + } + + @computed get phase() { + return this.entity.status.phase; + } + + get labels() { + const labels: string[] = []; + + Object.keys(this.entity.metadata.labels).forEach((key) => { + const value = this.entity.metadata.labels[key]; + + labels.push(`${key}=${value}`); + }); + + return labels; + } + + get source() { + return this.entity.metadata.source || "unknown"; + } + + onRun(ctx: CatalogEntityActionContext) { + this.entity.onRun(ctx); + } + + @action + async onContextMenuOpen(ctx: any) { + return this.entity.onContextMenuOpen(ctx); + } +} + +@autobind() +export class CatalogEntityStore extends ItemStore { + @observable activeCategory: CatalogCategory; + + @computed get entities() { + if (!this.activeCategory) return []; + + console.log("computing entities", this.activeCategory); + + return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); + } + + watch() { + const disposers: IReactionDisposer[] = [ + reaction(() => this.entities, () => this.loadAll()), + reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}) + ]; + + return () => disposers.forEach((dispose) => dispose()); + } + + loadAll() { + return this.loadItems(() => this.entities); + } +} diff --git a/src/renderer/components/+catalog/catalog.route.ts b/src/renderer/components/+catalog/catalog.route.ts new file mode 100644 index 0000000000..68d4c3d022 --- /dev/null +++ b/src/renderer/components/+catalog/catalog.route.ts @@ -0,0 +1,8 @@ +import type { RouteProps } from "react-router"; +import { buildURL } from "../../../common/utils/buildUrl"; + +export const catalogRoute: RouteProps = { + path: "/catalog" +}; + +export const catalogURL = buildURL(catalogRoute.path); diff --git a/src/renderer/components/+catalog/catalog.scss b/src/renderer/components/+catalog/catalog.scss new file mode 100644 index 0000000000..302175d1f8 --- /dev/null +++ b/src/renderer/components/+catalog/catalog.scss @@ -0,0 +1,26 @@ +.CatalogPage { + --width: 100%; + --height: 100%; + --nav-column-width: 230px; + text-align: left; + + .sidebarRegion { + justify-content: flex-start; + } + + .contentRegion { + .content { + padding: 20px 20px; + } + } + + .TableCell.status { + &.connected { + color: var(--colorSuccess); + } + + &.disconnected { + color: var(--halfGray); + } + } +} diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx new file mode 100644 index 0000000000..1a1d02f587 --- /dev/null +++ b/src/renderer/components/+catalog/catalog.tsx @@ -0,0 +1,195 @@ +import "./catalog.scss"; +import React from "react"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { ItemListLayout } from "../item-object-list"; +import { observable, reaction } 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 { Icon } from "../icon"; +import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; +import { Badge } from "../badge"; +import { hotbarStore } from "../../../common/hotbar-store"; +import { addClusterURL } from "../+add-cluster"; +import { autobind } from "../../utils"; +import { Notifications } from "../notifications"; +import { ConfirmDialog } from "../confirm-dialog"; +import { Tab, Tabs } from "../tabs"; +import { catalogCategoryRegistry } from "../../../common/catalog-category-registry"; + +enum sortBy { + name = "name", + source = "source", + status = "status" +} +@observer +export class Catalog extends React.Component { + @observable private catalogEntityStore?: CatalogEntityStore; + @observable.deep private contextMenu: CatalogEntityContextMenuContext; + @observable activeTab: string; + + async componentDidMount() { + this.contextMenu = { + menuItems: [], + navigate: (url: string) => navigate(url) + }; + this.catalogEntityStore = new CatalogEntityStore(); + disposeOnUnmount(this, [ + this.catalogEntityStore.watch(), + reaction(() => catalogCategoryRegistry.items, (items) => { + if (!this.activeTab && items.length > 0) { + this.activeTab = items[0].getId(); + this.catalogEntityStore.activeCategory = items[0]; + } + }, { fireImmediately: true }) + ]); + + setTimeout(() => { + if (this.catalogEntityStore.items.length === 0) { + Notifications.info(<>Welcome!

Get started by associating one or more clusters to Lens

, { + timeout: 30_000, + id: "catalog-welcome" + }); + } + }, 2_000); + } + + addToHotbar(item: CatalogEntityItem) { + const hotbar = hotbarStore.getByName("default"); // FIXME + + if (!hotbar) { + return; + } + + hotbar.items.push({ entity: { uid: item.id }}); + } + + removeFromHotbar(item: CatalogEntityItem) { + const hotbar = hotbarStore.getByName("default"); // FIXME + + if (!hotbar) { + return; + } + + hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.id); + } + + onDetails(item: CatalogEntityItem) { + 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; + } + + onTabChange = (tabId: string) => { + this.activeTab = tabId; + + const activeCategory = this.categories.find((category) => category.getId() === tabId); + + if (activeCategory) { + this.catalogEntityStore.activeCategory = activeCategory; + } + }; + + renderNavigation() { + return ( + +
Catalog
+ { this.categories.map((category, index) => { + return ; + })} +
+ ); + } + + @autobind() + renderItemMenu(item: CatalogEntityItem) { + const onOpen = async () => { + await item.onContextMenuOpen(this.contextMenu); + }; + + return ( + onOpen()}> + this.addToHotbar(item) }> + Add to Hotbar + + this.removeFromHotbar(item) }> + Remove from Hotbar + + { this.contextMenu.menuItems.map((menuItem, index) => { + return ( + this.onMenuItemClick(menuItem)}> + {menuItem.title} + + ); + })} + + ); + } + + render() { + if (!this.catalogEntityStore) { + return null; + } + + return ( + + item.name, + [sortBy.source]: (item: CatalogEntityItem) => item.source, + [sortBy.status]: (item: CatalogEntityItem) => item.phase, + }} + renderTableHeader={[ + { title: "Name", className: "name", sortBy: sortBy.name }, + { title: "Source", className: "source" }, + { title: "Labels", className: "labels" }, + { title: "Status", className: "status", sortBy: sortBy.status }, + ]} + renderTableContents={(item: CatalogEntityItem) => [ + item.name, + item.source, + item.labels.map((label) => ), + { title: item.phase, className: kebabCase(item.phase) } + ]} + onDetails={(item: CatalogEntityItem) => this.onDetails(item) } + renderItemMenu={this.renderItemMenu} + addRemoveButtons={{ + addTooltip: "Add Kubernetes Cluster", + onAdd: () => navigate(addClusterURL()), + }} + /> + + ); + } +} diff --git a/src/renderer/components/+catalog/index.tsx b/src/renderer/components/+catalog/index.tsx new file mode 100644 index 0000000000..207fed72d0 --- /dev/null +++ b/src/renderer/components/+catalog/index.tsx @@ -0,0 +1,2 @@ +export * from "./catalog.route"; +export * from "./catalog"; diff --git a/src/renderer/components/+cluster-settings/cluster-settings.command.ts b/src/renderer/components/+cluster-settings/cluster-settings.command.ts index a3b3c8792e..c2e762eca8 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.command.ts +++ b/src/renderer/components/+cluster-settings/cluster-settings.command.ts @@ -12,5 +12,5 @@ commandRegistry.add({ clusterId: clusterStore.active.id } })), - isActive: (context) => !!context.cluster + 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 40472be1ec..0334f997f7 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -4,12 +4,9 @@ import React from "react"; import { reaction } from "mobx"; import { RouteComponentProps } from "react-router"; import { observer, disposeOnUnmount } from "mobx-react"; -import { Features } from "./features"; -import { Removal } from "./removal"; import { Status } from "./status"; import { General } from "./general"; import { Cluster } from "../../../main/cluster"; -import { ClusterIcon } from "../cluster-icon"; import { IClusterSettingsRouteParams } from "./cluster-settings.route"; import { clusterStore } from "../../../common/cluster-store"; import { PageLayout } from "../layout/page-layout"; @@ -58,7 +55,6 @@ export class ClusterSettings extends React.Component { if (!cluster) return null; const header = ( <> -

{cluster.preferences.clusterName}

); @@ -67,8 +63,6 @@ export class ClusterSettings extends React.Component { - - ); } diff --git a/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx deleted file mode 100644 index 466afc14da..0000000000 --- a/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; -import { autobind } from "../../../utils"; -import { Button } from "../../button"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import { SubTitle } from "../../layout/sub-title"; -import { ClusterIcon } from "../../cluster-icon"; - -enum GeneralInputStatus { - CLEAN = "clean", - ERROR = "error", -} - -interface Props { - cluster: Cluster; -} - -@observer -export class ClusterIconSetting extends React.Component { - @observable status = GeneralInputStatus.CLEAN; - @observable errorText?: string; - - @autobind() - async onIconPick([file]: File[]) { - const { cluster } = this.props; - - try { - if (file) { - const buf = Buffer.from(await file.arrayBuffer()); - - cluster.preferences.icon = `data:${file.type};base64,${buf.toString("base64")}`; - } else { - // this has to be done as a seperate branch (and not always) because `cluster` - // is observable and triggers an update loop. - cluster.preferences.icon = undefined; - } - } catch (e) { - this.errorText = e.toString(); - this.status = GeneralInputStatus.ERROR; - } - } - - getClearButton() { - if (this.props.cluster.preferences.icon) { - return ; - } - } - - render() { - const label = ( - <> - - {"Browse for new icon..."} - - ); - - return ( - <> - -

Define cluster icon. By default automatically generated.

-
- - {this.getClearButton()} -
- - ); - } -} diff --git a/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx deleted file mode 100644 index fa76dde806..0000000000 --- a/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -import { workspaceStore } from "../../../../common/workspace-store"; -import { Cluster } from "../../../../main/cluster"; -import { Select } from "../../../components/select"; -import { SubTitle } from "../../layout/sub-title"; - -interface Props { - cluster: Cluster; -} - -@observer -export class ClusterWorkspaceSetting extends React.Component { - render() { - return ( - <> - -

- Define cluster workspace. -

- this.onSubmit(v)} - dirty={true} - showValidationLine={true} /> - - Please provide a new workspace name (Press "Enter" to confirm or "Escape" to cancel) - - - ); - } -} - -commandRegistry.add({ - id: "workspace.addWorkspace", - title: "Workspace: Add workspace ...", - scope: "global", - action: () => CommandOverlay.open() -}); diff --git a/src/renderer/components/+workspaces/edit-workspace.tsx b/src/renderer/components/+workspaces/edit-workspace.tsx deleted file mode 100644 index 3ab4b44d5a..0000000000 --- a/src/renderer/components/+workspaces/edit-workspace.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store"; -import { commandRegistry } from "../../../extensions/registries/command-registry"; -import { Input, InputValidator } from "../input"; -import { CommandOverlay } from "../command-palette/command-container"; - -const validateWorkspaceName: InputValidator = { - condition: ({ required }) => required, - message: () => `Workspace with this name already exists`, - validate: (value) => { - const current = workspaceStore.currentWorkspace; - - if (current.name === value.trim()) { - return true; - } - - return !workspaceStore.enabledWorkspacesList.find((workspace) => workspace.name === value); - } -}; - -interface EditWorkspaceState { - name: string; -} - -@observer -export class EditWorkspace extends React.Component<{}, EditWorkspaceState> { - - state: EditWorkspaceState = { - name: "" - }; - - componentDidMount() { - this.setState({name: workspaceStore.currentWorkspace.name}); - } - - onSubmit(name: string) { - if (name.trim() === "") { - return; - } - - workspaceStore.currentWorkspace.name = name; - CommandOverlay.close(); - } - - onChange(name: string) { - this.setState({name}); - } - - get name() { - return this.state.name; - } - - render() { - return ( - <> - this.onChange(v)} - onSubmit={(v) => this.onSubmit(v)} - dirty={true} - value={this.name} - showValidationLine={true} /> - - Please provide a new workspace name (Press "Enter" to confirm or "Escape" to cancel) - - - ); - } -} - -commandRegistry.add({ - id: "workspace.editCurrentWorkspace", - title: "Workspace: Edit current workspace ...", - scope: "global", - action: () => CommandOverlay.open(), - isActive: (context) => context.workspace?.id !== WorkspaceStore.defaultId -}); diff --git a/src/renderer/components/+workspaces/index.ts b/src/renderer/components/+workspaces/index.ts deleted file mode 100644 index 5b84fc9b00..0000000000 --- a/src/renderer/components/+workspaces/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./workspaces"; diff --git a/src/renderer/components/+workspaces/remove-workspace.tsx b/src/renderer/components/+workspaces/remove-workspace.tsx deleted file mode 100644 index 9f66292447..0000000000 --- a/src/renderer/components/+workspaces/remove-workspace.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -import { computed} from "mobx"; -import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store"; -import { ConfirmDialog } from "../confirm-dialog"; -import { commandRegistry } from "../../../extensions/registries/command-registry"; -import { Select } from "../select"; -import { CommandOverlay } from "../command-palette/command-container"; - -@observer -export class RemoveWorkspace extends React.Component { - @computed get options() { - return workspaceStore.enabledWorkspacesList.filter((workspace) => workspace.id !== WorkspaceStore.defaultId).map((workspace) => { - return { value: workspace.id, label: workspace.name }; - }); - } - - onChange(id: string) { - const workspace = workspaceStore.enabledWorkspacesList.find((workspace) => workspace.id === id); - - if (!workspace ) { - return; - } - - CommandOverlay.close(); - ConfirmDialog.open({ - okButtonProps: { - label: `Remove Workspace`, - primary: false, - accent: true, - }, - ok: () => { - workspaceStore.removeWorkspace(workspace); - }, - message: ( -
-

- Are you sure you want remove workspace {workspace.name}? -

-

- All clusters within workspace will be cleared as well -

-
- ), - }); - } - - render() { - return ( - this.onChange(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Switch to workspace" /> - ); - } -} - -commandRegistry.add({ - id: "workspace.chooseWorkspace", - title: "Workspace: Switch to workspace ...", - scope: "global", - action: () => CommandOverlay.open() -}); diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index f5ad684595..2110898885 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { computed, observable, reaction } from "mobx"; +import { observable } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Redirect, Route, Router, Switch } from "react-router"; import { history } from "../navigation"; @@ -36,7 +36,7 @@ import { webFrame } from "electron"; import { clusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry"; import { extensionLoader } from "../../extensions/extension-loader"; import { appEventBus } from "../../common/event-bus"; -import { broadcastMessage, requestMain } from "../../common/ipc"; +import { requestMain } from "../../common/ipc"; import whatInput from "what-input"; import { clusterSetFrameIdHandler } from "../../common/cluster-ipc"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries"; @@ -86,20 +86,12 @@ export class App extends React.Component { disposeOnUnmount(this, [ kubeWatchApi.subscribeStores([podsStore, nodesStore, eventStore], { preload: true, - }), - - reaction(() => this.warningsTotal, (count: number) => { - broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); - }), + }) ]); } @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL(); - @computed get warningsTotal(): number { - return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); - } - getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { const routes: TabLayoutRoute[] = []; diff --git a/src/renderer/components/cluster-icon/cluster-icon.scss b/src/renderer/components/cluster-icon/cluster-icon.scss deleted file mode 100644 index 540cecf9eb..0000000000 --- a/src/renderer/components/cluster-icon/cluster-icon.scss +++ /dev/null @@ -1,38 +0,0 @@ -.ClusterIcon { - --size: 37px; - - position: relative; - border-radius: $radius; - padding: $radius; - user-select: none; - cursor: pointer; - - &.interactive { - img { - opacity: .55; - } - } - - &.active, &.interactive:hover { - background-color: #fff; - - img { - opacity: 1; - } - } - - img { - width: var(--size); - height: var(--size); - } - - .Badge { - position: absolute; - right: 0; - bottom: 0; - margin: -$padding; - font-size: $font-size-small; - background: $colorError; - color: white; - } -} \ No newline at end of file diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx deleted file mode 100644 index 48d23eb91f..0000000000 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import "./cluster-icon.scss"; - -import React, { DOMAttributes } from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { Params as HashiconParams } from "@emeraldpay/hashicon"; -import { Hashicon } from "@emeraldpay/hashicon-react"; -import { Cluster } from "../../../main/cluster"; -import { cssNames, IClassName } from "../../utils"; -import { Badge } from "../badge"; -import { Tooltip } from "../tooltip"; -import { subscribeToBroadcast } from "../../../common/ipc"; -import { observable } from "mobx"; - -interface Props extends DOMAttributes { - cluster: Cluster; - className?: IClassName; - errorClass?: IClassName; - showErrors?: boolean; - showTooltip?: boolean; - interactive?: boolean; - isActive?: boolean; - options?: HashiconParams; -} - -const defaultProps: Partial = { - showErrors: true, - showTooltip: true, -}; - -@observer -export class ClusterIcon extends React.Component { - static defaultProps = defaultProps as object; - - @observable eventCount = 0; - - get eventCountBroadcast() { - return `cluster-warning-event-count:${this.props.cluster.id}`; - } - - componentDidMount() { - const subscriber = subscribeToBroadcast(this.eventCountBroadcast, (ev, eventCount) => { - this.eventCount = eventCount; - }); - - disposeOnUnmount(this, [ - subscriber - ]); - } - - render() { - const { - cluster, showErrors, showTooltip, errorClass, options, interactive, isActive, - children, ...elemProps - } = this.props; - const { name, preferences, id: clusterId, online } = cluster; - const eventCount = this.eventCount; - const { icon } = preferences; - const clusterIconId = `cluster-icon-${clusterId}`; - const className = cssNames("ClusterIcon flex inline", this.props.className, { - interactive: interactive !== undefined ? interactive : !!this.props.onClick, - active: isActive, - }); - - return ( -
- {showTooltip && ( - {name} - )} - {icon && {name}/} - {!icon && } - {showErrors && eventCount > 0 && !isActive && online && ( - = 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount} - /> - )} - {children} -
- ); - } -} diff --git a/src/renderer/components/cluster-icon/index.ts b/src/renderer/components/cluster-icon/index.ts deleted file mode 100644 index 7879490b85..0000000000 --- a/src/renderer/components/cluster-icon/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./cluster-icon"; diff --git a/src/renderer/components/cluster-manager/bottom-bar.scss b/src/renderer/components/cluster-manager/bottom-bar.scss index 0f290eaf82..83ca2570db 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.scss +++ b/src/renderer/components/cluster-manager/bottom-bar.scss @@ -6,7 +6,7 @@ padding: 0 2px; height: var(--bottom-bar-height); - #current-workspace { + #catalog-link { font-size: var(--font-size-small); color: white; padding: $padding / 4 $padding / 2; diff --git a/src/renderer/components/cluster-manager/bottom-bar.tsx b/src/renderer/components/cluster-manager/bottom-bar.tsx index e192c67dc6..eb82fdb6b2 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.tsx @@ -2,11 +2,10 @@ import "./bottom-bar.scss"; import React from "react"; import { observer } from "mobx-react"; -import { Icon } from "../icon"; -import { workspaceStore } from "../../../common/workspace-store"; import { StatusBarRegistration, statusBarRegistry } from "../../../extensions/registries"; -import { CommandOverlay } from "../command-palette/command-container"; -import { ChooseWorkspace } from "../+workspaces"; +import { navigate } from "../../navigation"; +import { catalogURL } from "../+catalog"; +import { Icon } from "../icon"; @observer export class BottomBar extends React.Component { @@ -45,13 +44,11 @@ export class BottomBar extends React.Component { } render() { - const { currentWorkspace } = workspaceStore; - return (
-
CommandOverlay.open()}> - - {currentWorkspace.name} + {this.renderRegisteredItems()}
diff --git a/src/renderer/components/cluster-manager/cluster-actions.tsx b/src/renderer/components/cluster-manager/cluster-actions.tsx index 93bde6d80d..5015abbcac 100644 --- a/src/renderer/components/cluster-manager/cluster-actions.tsx +++ b/src/renderer/components/cluster-manager/cluster-actions.tsx @@ -1,7 +1,7 @@ import React from "react"; import uniqueId from "lodash/uniqueId"; import { clusterSettingsURL } from "../+cluster-settings"; -import { landingURL } from "../+landing-page"; +import { catalogURL } from "../+catalog"; import { clusterStore } from "../../../common/cluster-store"; import { broadcastMessage, requestMain } from "../../../common/ipc"; @@ -25,7 +25,7 @@ export const ClusterActions = (cluster: Cluster) => ({ })), disconnect: async () => { clusterStore.deactivate(cluster.id); - navigate(landingURL()); + navigate(catalogURL()); await requestMain(clusterDisconnectHandler, cluster.id); }, remove: () => { @@ -40,7 +40,7 @@ export const ClusterActions = (cluster: Cluster) => ({ ok: () => { clusterStore.deactivate(cluster.id); clusterStore.removeById(cluster.id); - navigate(landingURL()); + navigate(catalogURL()); }, message:

Are you sure want to remove cluster {cluster.name}? diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index c283d9aabf..a7d081cc4d 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -13,7 +13,7 @@ display: flex; } - .ClustersMenu { + .HotbarMenu { grid-area: menu; } @@ -34,4 +34,4 @@ flex: 1; } } -} \ No newline at end of file +} diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 1c3306b65f..6fb6646d32 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -4,19 +4,19 @@ import React from "react"; import { Redirect, Route, Switch } from "react-router"; import { comparer, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { ClustersMenu } from "./clusters-menu"; import { BottomBar } from "./bottom-bar"; -import { LandingPage, landingRoute, landingURL } from "../+landing-page"; +import { Catalog, catalogRoute, catalogURL } from "../+catalog"; import { Preferences, preferencesRoute } from "../+preferences"; import { AddCluster, addClusterRoute } from "../+add-cluster"; import { ClusterView } from "./cluster-view"; import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings"; -import { clusterViewRoute, clusterViewURL } from "./cluster-view.route"; +import { clusterViewRoute } from "./cluster-view.route"; import { clusterStore } from "../../../common/cluster-store"; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import { globalPageRegistry } from "../../../extensions/registries/page-registry"; import { Extensions, extensionsRoute } from "../+extensions"; import { getMatchedClusterId } from "../../navigation"; +import { HotbarMenu } from "../hotbar/hotbar-menu"; @observer export class ClusterManager extends React.Component { @@ -44,17 +44,7 @@ export class ClusterManager extends React.Component { } get startUrl() { - const { activeClusterId } = clusterStore; - - if (activeClusterId) { - return clusterViewURL({ - params: { - clusterId: activeClusterId - } - }); - } - - return landingURL(); + return catalogURL(); } render() { @@ -63,7 +53,7 @@ export class ClusterManager extends React.Component {

- + @@ -75,7 +65,7 @@ export class ClusterManager extends React.Component {
- +
); diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index ddedf145e7..8d89b7a81d 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -8,6 +8,8 @@ import { ClusterStatus } from "./cluster-status"; import { hasLoadedView } from "./lens-views"; import { Cluster } from "../../../main/cluster"; import { clusterStore } from "../../../common/cluster-store"; +import { navigate } from "../../navigation"; +import { catalogURL } from "../+catalog"; interface Props extends RouteComponentProps { } @@ -26,6 +28,9 @@ export class ClusterView extends React.Component { disposeOnUnmount(this, [ reaction(() => this.clusterId, clusterId => clusterStore.setActive(clusterId), { fireImmediately: true, + }), + reaction(() => this.cluster.online, (online) => { + if (!online) navigate(catalogURL()); }) ]); } diff --git a/src/renderer/components/cluster-manager/clusters-menu.scss b/src/renderer/components/cluster-manager/clusters-menu.scss deleted file mode 100644 index 5256209b0a..0000000000 --- a/src/renderer/components/cluster-manager/clusters-menu.scss +++ /dev/null @@ -1,67 +0,0 @@ -.ClustersMenu { - $spacing: $padding * 2; - - position: relative; - text-align: center; - background: $clusterMenuBackground; - border-right: 1px solid $clusterMenuBorderColor; - padding: $spacing 0; - min-width: 75px; - - .is-mac &:before { - content: ""; - height: 20px; // extra spacing for mac-os "traffic-light" buttons - } - - .clusters { - @include hidden-scrollbar; - padding: 0 $spacing; // extra spacing for cluster-icon's badge - margin-bottom: $margin; - - .ClusterIcon { - margin-bottom: $margin * 1.5; - } - - &:empty { - display: none; - } - } - - > .WorkspaceMenu { - position: relative; - margin-bottom: $margin; - - .Icon { - margin-bottom: $margin * 1.5; - border-radius: $radius; - padding: $padding / 3; - color: var(--textColorPrimary); - background: unset; - cursor: pointer; - - &.active { - opacity: 1; - } - - &:hover { - box-shadow: none; - color: var(--textColorAccent); - background-color: unset; - } - } - } - - > .extensions { - &:not(:empty) { - padding-top: $spacing; - } - - .Icon { - --size: 40px; - } - } -} - -.Menu.WorkspaceMenu { - z-index: 2; // Place behind Preferences, Extension pages etc... -} \ No newline at end of file diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx deleted file mode 100644 index 03711e41fc..0000000000 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import "./clusters-menu.scss"; - -import React from "react"; -import { remote } from "electron"; -import type { Cluster } from "../../../main/cluster"; -import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; -import { observer } from "mobx-react"; -import { ClusterId, clusterStore } from "../../../common/cluster-store"; -import { workspaceStore } from "../../../common/workspace-store"; -import { ClusterIcon } from "../cluster-icon"; -import { Icon } from "../icon"; -import { autobind, cssNames, IClassName } from "../../utils"; -import { isActiveRoute, navigate } from "../../navigation"; -import { addClusterURL } from "../+add-cluster"; -import { landingURL } from "../+landing-page"; -import { clusterViewURL } from "./cluster-view.route"; -import { ClusterActions } from "./cluster-actions"; -import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; -import { commandRegistry } from "../../../extensions/registries/command-registry"; -import { CommandOverlay } from "../command-palette/command-container"; -import { computed, observable } from "mobx"; -import { Select } from "../select"; -import { Menu, MenuItem } from "../menu"; - -interface Props { - className?: IClassName; -} - -@observer -export class ClustersMenu extends React.Component { - @observable workspaceMenuVisible = false; - - showCluster = (clusterId: ClusterId) => { - navigate(clusterViewURL({ params: { clusterId } })); - }; - - showContextMenu = (cluster: Cluster) => { - const { Menu, MenuItem } = remote; - const menu = new Menu(); - const actions = ClusterActions(cluster); - - menu.append(new MenuItem({ - label: `Settings`, - click: actions.showSettings - })); - - if (cluster.online) { - menu.append(new MenuItem({ - label: `Disconnect`, - click: actions.disconnect - })); - } - - if (!cluster.isManaged) { - menu.append(new MenuItem({ - label: `Remove`, - click: actions.remove - })); - } - menu.popup({ - window: remote.getCurrentWindow() - }); - }; - - @autobind() - swapClusterIconOrder(result: DropResult) { - if (result.reason === "DROP") { - const { currentWorkspaceId } = workspaceStore; - const { - source: { index: from }, - destination: { index: to }, - } = result; - - clusterStore.swapIconOrders(currentWorkspaceId, from, to); - } - } - - render() { - const { className } = this.props; - const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); - const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); - const activeClusterId = clusterStore.activeCluster; - - return ( -
-
- - - {({ innerRef, droppableProps, placeholder }: DroppableProvided) => ( -
- {clusters.map((cluster, index) => { - const isActive = cluster.id === activeClusterId; - - return ( - - {({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => ( -
- this.showCluster(cluster.id)} - onContextMenu={() => this.showContextMenu(cluster)} - /> -
- )} -
- ); - })} - {placeholder} -
- )} -
-
-
- -
- - this.workspaceMenuVisible = true} - close={() => this.workspaceMenuVisible = false} - toggleEvent="click" - > - navigate(addClusterURL())} data-test-id="add-cluster-menu-item"> - Add Cluster - - navigate(landingURL())} data-test-id="workspace-overview-menu-item"> - Workspace Overview - - -
-
- {globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { - const registeredPage = globalPageRegistry.getByPageTarget(target); - - if (!registeredPage){ - return; - } - const pageUrl = getExtensionPageUrl(target); - const isActive = isActiveRoute(registeredPage.url); - - return ( - navigate(pageUrl)} - /> - ); - })} -
-
- ); - } -} - -@observer -export class ChooseCluster extends React.Component { - @computed get options() { - const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId).filter(cluster => cluster.enabled); - const options = clusters.map((cluster) => { - return { value: cluster.id, label: cluster.name }; - }); - - return options; - } - - onChange(clusterId: string) { - navigate(clusterViewURL({ params: { clusterId } })); - CommandOverlay.close(); - } - - render() { - return ( -