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

Catalog & Hotbar - initial groundwork (#2418)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-04-09 09:11:58 +03:00 committed by GitHub
parent e18a041b13
commit 99a464c61d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 1604 additions and 2612 deletions

View File

@ -12,7 +12,6 @@
"dev": "npm run build --watch", "dev": "npm run build --watch",
"test": "jest --passWithNoTests --env=jsdom src $@" "test": "jest --passWithNoTests --env=jsdom src $@"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@k8slens/extensions": "file:../../src/extensions/npm/extensions", "@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"jest": "^26.6.3", "jest": "^26.6.3",

View File

@ -1,21 +1,55 @@
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension, Store, Interface, Component } from "@k8slens/extensions";
import { MetricsFeature } from "./src/metrics-feature"; import { MetricsFeature } from "./src/metrics-feature";
import React from "react";
export default class ClusterMetricsFeatureExtension extends LensRendererExtension { export default class ClusterMetricsFeatureExtension extends LensRendererExtension {
clusterFeatures = [ onActivate() {
{ const category = Store.catalogCategories.getForGroupKind<Store.KubernetesClusterCategory>("entity.k8slens.dev", "KubernetesCluster");
title: "Metrics Stack",
components: { if (!category) {
Description: () => ( return;
<span>
Enable timeseries data visualization (Prometheus stack) for your cluster.
Install this only if you don&apos;t have existing Prometheus stack installed.
You can see preview of manifests <a href="https://github.com/lensapp/lens/tree/master/extensions/metrics-cluster-feature/resources" rel="noreferrer" target="_blank">here</a>.
</span>
)
},
feature: new MetricsFeature()
} }
];
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 });
}
});
}
}
} }

View File

@ -49,7 +49,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
storageClass: null, storageClass: null,
}; };
async install(cluster: Store.Cluster): Promise<void> { async install(cluster: Store.KubernetesCluster): Promise<void> {
// Check if there are storageclasses // Check if there are storageclasses
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass); const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
const scs = await storageClassApi.list(); const scs = await storageClassApi.list();
@ -62,11 +62,11 @@ export class MetricsFeature extends ClusterFeature.Feature {
super.applyResources(cluster, path.join(__dirname, "../resources/")); super.applyResources(cluster, path.join(__dirname, "../resources/"));
} }
async upgrade(cluster: Store.Cluster): Promise<void> { async upgrade(cluster: Store.KubernetesCluster): Promise<void> {
return this.install(cluster); return this.install(cluster);
} }
async updateStatus(cluster: Store.Cluster): Promise<ClusterFeature.FeatureStatus> { async updateStatus(cluster: Store.KubernetesCluster): Promise<ClusterFeature.FeatureStatus> {
try { try {
const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet); const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet);
const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"}); const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"});
@ -87,12 +87,13 @@ export class MetricsFeature extends ClusterFeature.Feature {
return this.status; return this.status;
} }
async uninstall(cluster: Store.Cluster): Promise<void> { async uninstall(cluster: Store.KubernetesCluster): Promise<void> {
const namespaceApi = K8sApi.forCluster(cluster, K8sApi.Namespace); const namespaceApi = K8sApi.forCluster(cluster, K8sApi.Namespace);
const clusterRoleBindingApi = K8sApi.forCluster(cluster, K8sApi.ClusterRoleBinding); const clusterRoleBindingApi = K8sApi.forCluster(cluster, K8sApi.ClusterRoleBinding);
const clusterRoleApi = K8sApi.forCluster(cluster, K8sApi.ClusterRole); const clusterRoleApi = K8sApi.forCluster(cluster, K8sApi.ClusterRole);
await namespaceApi.delete({name: "lens-metrics"}); await namespaceApi.delete({name: "lens-metrics"});
await clusterRoleBindingApi.delete({name: "lens-prometheus"}); await clusterRoleBindingApi.delete({name: "lens-prometheus"});
await clusterRoleApi.delete({name: "lens-prometheus"}); } await clusterRoleApi.delete({name: "lens-prometheus"});
}
} }

View File

@ -13,7 +13,6 @@
"dev": "webpack --watch", "dev": "webpack --watch",
"test": "jest --passWithNoTests --env=jsdom src $@" "test": "jest --passWithNoTests --env=jsdom src $@"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@k8slens/extensions": "file:../../src/extensions/npm/extensions", "@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"@types/analytics-node": "^3.1.3", "@types/analytics-node": "^3.1.3",

View File

@ -102,13 +102,12 @@ export class Tracker extends Util.Singleton {
} }
protected reportData() { protected reportData() {
const clustersList = Store.clusterStore.enabledClustersList; const clustersList = Store.catalogEntities.getItemsForApiKind<Store.KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster");
this.event("generic-data", "report", { this.event("generic-data", "report", {
appVersion: App.version, appVersion: App.version,
os: this.os, os: this.os,
clustersCount: clustersList.length, clustersCount: clustersList.length,
workspacesCount: Store.workspaceStore.enabledWorkspacesList.length,
extensions: App.getEnabledExtensions() 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", { this.event("cluster-data", "report", {
id: cluster.metadata.id, id: cluster.metadata.id,
managed: !!cluster.ownerRef, managed: cluster.metadata.source !== "local",
kubernetesVersion: cluster.metadata.version, kubernetesVersion: cluster.metadata.version,
distribution: cluster.metadata.distribution, distribution: cluster.metadata.distribution,
nodesCount: cluster.metadata.nodes, nodesCount: cluster.metadata.nodes,

View File

@ -26,6 +26,7 @@ describe("Lens cluster pages", () => {
const addCluster = async () => { const addCluster = async () => {
await utils.clickWhatsNew(app); await utils.clickWhatsNew(app);
await utils.clickWelcomeNotification(app); await utils.clickWelcomeNotification(app);
await app.client.waitUntilTextExists("div", "Catalog");
await addMinikubeCluster(app); await addMinikubeCluster(app);
await waitForMinikubeDashboard(app); await waitForMinikubeDashboard(app);
await app.client.click('a[href="/nodes"]'); await app.client.click('a[href="/nodes"]');

View File

@ -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"]`);
});
});
});

View File

@ -49,6 +49,8 @@ export async function addMinikubeCluster(app: Application) {
} // else the only context, which must be 'minikube', is automatically selected } // 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("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.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) { export async function waitForMinikubeDashboard(app: Application) {

View File

@ -80,7 +80,7 @@ export async function appStart() {
export async function clickWhatsNew(app: Application) { export async function clickWhatsNew(app: Application) {
await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary"); await app.client.click("button.primary");
await app.client.waitUntilTextExists("h5", "Clusters"); await app.client.waitUntilTextExists("div", "Catalog");
} }
export async function clickWelcomeNotification(app: Application) { export async function clickWelcomeNotification(app: Application) {

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.2.0-rc.3", "version": "5.0.0-alpha.0",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021, Mirantis, Inc.", "copyright": "© 2021, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -17,7 +17,7 @@
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"",
"dev:main": "yarn run compile:main --watch", "dev:main": "yarn run compile:main --watch",
"dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", "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": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "yarn run webpack --config webpack.main.ts", "compile:main": "yarn run webpack --config webpack.main.ts",
"compile:renderer": "yarn run webpack --config webpack.renderer.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts",

View File

@ -3,7 +3,6 @@ import mockFs from "mock-fs";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { Cluster } from "../../main/cluster"; import { Cluster } from "../../main/cluster";
import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
const kubeconfig = ` const kubeconfig = `
@ -77,8 +76,7 @@ describe("empty config", () => {
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube" clusterName: "minikube"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig), kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig)
workspace: workspaceStore.currentWorkspaceId
}) })
); );
}); });
@ -92,12 +90,6 @@ describe("empty config", () => {
expect(storedCluster.enabled).toBe(true); 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 () => { it("removes cluster from store", async () => {
await clusterStore.removeById("foo"); await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeNull(); expect(clusterStore.getById("foo")).toBeNull();
@ -106,7 +98,6 @@ describe("empty config", () => {
it("sets active cluster", () => { it("sets active cluster", () => {
clusterStore.setActive("foo"); clusterStore.setActive("foo");
expect(clusterStore.active.id).toBe("foo"); expect(clusterStore.active.id).toBe("foo");
expect(workspaceStore.currentWorkspace.lastActiveClusterId).toBe("foo");
}); });
}); });
@ -119,8 +110,7 @@ describe("empty config", () => {
preferences: { preferences: {
clusterName: "prod" clusterName: "prod"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig), kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig)
workspace: "workstation"
}), }),
new Cluster({ new Cluster({
id: "dev", id: "dev",
@ -128,8 +118,7 @@ describe("empty config", () => {
preferences: { preferences: {
clusterName: "dev" clusterName: "dev"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig), kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig)
workspace: "workstation"
}) })
); );
}); });
@ -139,51 +128,11 @@ describe("empty config", () => {
expect(clusterStore.clusters.size).toBe(2); 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", () => { it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("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(() => { afterEach(() => {
mockFs.restore(); 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", () => { describe("pre 3.6.0-beta.1 config with an existing cluster", () => {

View File

@ -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<WorkspaceStore>().load();
});
afterEach(() => {
mockFs.restore();
});
it("default workspace should always exist", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.workspaces.size).toBe(1);
expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null);
});
it("default workspace should be enabled", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.workspaces.size).toBe(1);
expect(ws.getById(WorkspaceStore.defaultId).enabled).toBe(true);
});
it("cannot remove the default workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(() => ws.removeWorkspaceById(WorkspaceStore.defaultId)).toThrowError("Cannot remove");
});
it("can update workspace description", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
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<WorkspaceStore>();
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<WorkspaceStore>();
expect(() => ws.setActive("abc")).toThrow("doesn't exist");
});
it("can set a existent workspace to be active", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "abc",
name: "foobar",
}));
expect(() => ws.setActive("abc")).not.toThrowError();
});
it("can remove a workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
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<WorkspaceStore>();
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<WorkspaceStore>();
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<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "random",
name: " ",
}));
expect(ws.workspacesList.length).toBe(1); // default workspace only
});
it("trim workspace name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
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<WorkspaceStore>().load();
});
afterEach(() => {
mockFs.restore();
});
it("doesn't revert to default workspace", async () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.currentWorkspaceId).toBe("abc");
});
});
});

View File

@ -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<T extends CatalogCategory>(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<T extends CatalogCategory>(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();

View File

@ -0,0 +1,2 @@
export * from "./kubernetes-cluster";
export * from "./web-link";

View File

@ -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<KubernetesClusterCategory>(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());

View File

@ -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());

View File

@ -0,0 +1,32 @@
import { action, computed, observable } from "mobx";
import { CatalogEntity } from "./catalog-entity";
export class CatalogEntityRegistry {
protected sources = observable.map<string, CatalogEntity[]>([], { 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<T extends CatalogEntity>(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();

View File

@ -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<void>;
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<void>;
onDetailsOpen: (context: CatalogEntityActionContext) => Promise<void>;
onContextMenuOpen: (context: CatalogEntityContextMenuContext) => Promise<void>;
}

View File

@ -1,4 +1,3 @@
import { workspaceStore } from "./workspace-store";
import path from "path"; import path from "path";
import { app, ipcRenderer, remote, webFrame } from "electron"; import { app, ipcRenderer, remote, webFrame } from "electron";
import { unlink } from "fs-extra"; import { unlink } from "fs-extra";
@ -12,9 +11,6 @@ import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles"; import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node"; import { KubeConfig } from "@kubernetes/client-node";
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; 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"; import { ResourceType } from "../renderer/components/+cluster-settings/components/cluster-metrics-setting";
export interface ClusterIconUpload { export interface ClusterIconUpload {
@ -47,8 +43,12 @@ export interface ClusterModel {
/** Path to cluster kubeconfig */ /** Path to cluster kubeconfig */
kubeConfigPath: string; kubeConfigPath: string;
/** Workspace id */ /**
workspace?: WorkspaceId; * Workspace id
*
* @deprecated
*/
workspace?: string;
/** User context in kubeconfig */ /** User context in kubeconfig */
contextName?: string; contextName?: string;
@ -226,7 +226,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
this.activeCluster = clusterId; this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
} }
deactivate(id: ClusterId) { deactivate(id: ClusterId) {
@ -235,22 +234,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
} }
@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() { hasClusters() {
return this.clusters.size > 0; return this.clusters.size > 0;
} }
@ -259,13 +242,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.get(id) ?? null; 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 @action
addClusters(...models: ClusterModel[]): Cluster[] { addClusters(...models: ClusterModel[]): Cluster[] {
const clusters: Cluster[] = []; const clusters: Cluster[] = [];
@ -317,13 +293,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
} }
@action
removeByWorkspaceId(workspaceId: string) {
this.getByWorkspaceId(workspaceId).forEach(cluster => {
this.removeById(cluster.id);
});
}
@action @action
protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) { protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
const currentClusters = this.clusters.toJS(); const currentClusters = this.clusters.toJS();

View File

@ -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<HotbarStoreModel> {
@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<HotbarStoreModel> = {}) {
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<HotbarStore>();

View File

@ -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<WorkspaceStoreModel> {
static readonly defaultId: WorkspaceId = "default";
private static stateRequestChannel = "workspace:states";
@observable currentWorkspaceId = WorkspaceStore.defaultId;
@observable workspaces = observable.map<WorkspaceId, Workspace>();
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<WorkspaceId>([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<WorkspaceStore>();

View File

@ -3,11 +3,12 @@ import path from "path";
import hb from "handlebars"; import hb from "handlebars";
import { observable } from "mobx"; import { observable } from "mobx";
import { ResourceApplier } from "../main/resource-applier"; import { ResourceApplier } from "../main/resource-applier";
import { Cluster } from "../main/cluster"; import { KubernetesCluster } from "./core-api/stores";
import logger from "../main/logger"; import logger from "../main/logger";
import { app } from "electron"; import { app } from "electron";
import { requestMain } from "../common/ipc"; import { requestMain } from "../common/ipc";
import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc"; import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc";
import { clusterStore } from "../common/cluster-store";
export interface ClusterFeatureStatus { export interface ClusterFeatureStatus {
/** feature's current version, as set by the implementation */ /** 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 * @param cluster the cluster that the feature is to be installed on
*/ */
abstract async install(cluster: Cluster): Promise<void>; abstract install(cluster: KubernetesCluster): Promise<void>;
/** /**
* 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 * 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 * @param cluster the cluster that the feature is to be upgraded on
*/ */
abstract async upgrade(cluster: Cluster): Promise<void>; abstract upgrade(cluster: KubernetesCluster): Promise<void>;
/** /**
* 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 * 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 * @param cluster the cluster that the feature is to be uninstalled from
*/ */
abstract async uninstall(cluster: Cluster): Promise<void>; abstract uninstall(cluster: KubernetesCluster): Promise<void>;
/** /**
* 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 * 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 * @return a promise, resolved with the updated ClusterFeatureStatus
*/ */
abstract async updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>; abstract updateStatus(cluster: KubernetesCluster): Promise<ClusterFeatureStatus>;
/** /**
* this is a helper method that conveniently applies kubernetes resources to the cluster. * 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 * 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 * 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[]; let resources: string[];
const clusterModel = clusterStore.getById(cluster.metadata.uid);
if (!clusterModel) {
throw new Error(`cluster not found`);
}
if ( typeof resourceSpec === "string" ) { if ( typeof resourceSpec === "string" ) {
resources = this.renderTemplates(resourceSpec); resources = this.renderTemplates(resourceSpec);
} else { } else {
@ -92,9 +99,9 @@ export abstract class ClusterFeature {
} }
if (app) { if (app) {
await new ResourceApplier(cluster).kubectlApplyAll(resources); await new ResourceApplier(clusterModel).kubectlApplyAll(resources);
} else { } else {
await requestMain(clusterKubectlApplyAllHandler, cluster.id, resources); await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources);
} }
} }

View File

@ -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<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
return registry.getItemsForApiKind<T>(apiVersion, kind);
}
}
export const catalogEntities = new CatalogEntityRegistry();

View File

@ -1,8 +1,4 @@
export { ExtensionStore } from "../extension-store"; export { ExtensionStore } from "../extension-store";
export { KubernetesCluster, KubernetesClusterCategory } from "../../common/catalog-entities/kubernetes-cluster";
export { clusterStore, Cluster, ClusterStore } from "../stores/cluster-store"; export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog-category-registry";
export type { ClusterModel, ClusterId } from "../stores/cluster-store"; export { catalogEntities } from "./catalog";
export { workspaceStore, Workspace, WorkspaceStore } from "../stores/workspace-store";
export type { WorkspaceId, WorkspaceModel } from "../stores/workspace-store";

View File

@ -213,7 +213,6 @@ export class ExtensionLoader {
registries.globalPageRegistry.add(extension.globalPages, extension), registries.globalPageRegistry.add(extension.globalPages, extension),
registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension), registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension),
registries.appPreferenceRegistry.add(extension.appPreferences), registries.appPreferenceRegistry.add(extension.appPreferences),
registries.clusterFeatureRegistry.add(extension.clusterFeatures),
registries.statusBarRegistry.add(extension.statusBarItems), registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands), registries.commandRegistry.add(extension.commands),
]; ];

View File

@ -0,0 +1 @@
export * from "../../common/catalog-entity";

View File

@ -1 +1,2 @@
export * from "./registrations"; export * from "./registrations";
export * from "./catalog";

View File

@ -1,5 +1,4 @@
export type { AppPreferenceRegistration, AppPreferenceComponents } from "../registries/app-preference-registry"; 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 { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry"; export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry";
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";

View File

@ -2,6 +2,8 @@ import type { MenuRegistration } from "./registries/menu-registry";
import { LensExtension } from "./lens-extension"; import { LensExtension } from "./lens-extension";
import { WindowManager } from "../main/window-manager"; import { WindowManager } from "../main/window-manager";
import { getExtensionPageUrl } from "./registries/page-registry"; import { getExtensionPageUrl } from "./registries/page-registry";
import { catalogEntityRegistry } from "../common/catalog-entity-registry";
import { CatalogEntity } from "../common/catalog-entity";
export class LensMainExtension extends LensExtension { export class LensMainExtension extends LensExtension {
appMenus: MenuRegistration[] = []; appMenus: MenuRegistration[] = [];
@ -16,4 +18,12 @@ export class LensMainExtension extends LensExtension {
await windowManager.navigate(pageUrl, frameId); await windowManager.navigate(pageUrl, frameId);
} }
addCatalogSource(id: string, source: CatalogEntity[]) {
catalogEntityRegistry.addSource(`${this.name}:${id}`, source);
}
removeCatalogSource(id: string) {
catalogEntityRegistry.removeSource(`${this.name}:${id}`);
}
} }

View File

@ -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 type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension"; import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry"; import { getExtensionPageUrl } from "./registries/page-registry";
@ -11,7 +11,6 @@ export class LensRendererExtension extends LensExtension {
clusterPageMenus: ClusterPageMenuRegistration[] = []; clusterPageMenus: ClusterPageMenuRegistration[] = [];
kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
appPreferences: AppPreferenceRegistration[] = []; appPreferences: AppPreferenceRegistration[] = [];
clusterFeatures: ClusterFeatureRegistration[] = [];
statusBarItems: StatusBarRegistration[] = []; statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];

View File

@ -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<any>;
}
export interface ClusterFeatureRegistration {
title: string;
components: ClusterFeatureComponents
feature: ClusterFeature
}
export class ClusterFeatureRegistry extends BaseRegistry<ClusterFeatureRegistration> {
}
export const clusterFeatureRegistry = new ClusterFeatureRegistry();

View File

@ -1,25 +1,25 @@
// Extensions API -> Commands // Extensions API -> Commands
import type { Cluster } from "../../main/cluster";
import type { Workspace } from "../../common/workspace-store";
import { BaseRegistry } from "./base-registry"; import { BaseRegistry } from "./base-registry";
import { action } from "mobx"; import { action, observable } from "mobx";
import { LensExtension } from "../lens-extension"; import { LensExtension } from "../lens-extension";
import { CatalogEntity } from "../../common/catalog-entity";
export type CommandContext = { export type CommandContext = {
cluster?: Cluster; entity?: CatalogEntity;
workspace?: Workspace;
}; };
export interface CommandRegistration { export interface CommandRegistration {
id: string; id: string;
title: string; title: string;
scope: "cluster" | "global"; scope: "entity" | "global";
action: (context: CommandContext) => void; action: (context: CommandContext) => void;
isActive?: (context: CommandContext) => boolean; isActive?: (context: CommandContext) => boolean;
} }
export class CommandRegistry extends BaseRegistry<CommandRegistration> { export class CommandRegistry extends BaseRegistry<CommandRegistration> {
@observable activeEntity: CatalogEntity;
@action @action
add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) { add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) {
const itemArray = [items].flat(); const itemArray = [items].flat();

View File

@ -7,6 +7,5 @@ export * from "./app-preference-registry";
export * from "./status-bar-registry"; export * from "./status-bar-registry";
export * from "./kube-object-detail-registry"; export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry"; export * from "./kube-object-menu-registry";
export * from "./cluster-feature-registry";
export * from "./kube-object-status-registry"; export * from "./kube-object-status-registry";
export * from "./command-registry"; export * from "./command-registry";

View File

@ -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<string, Cluster> {
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<ClusterStore>();

View File

@ -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<string, Workspace> {
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<WorkspaceStore>();

View File

@ -31,7 +31,6 @@ jest.mock("request-promise-native");
import { Console } from "console"; import { Console } from "console";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { workspaceStore } from "../../common/workspace-store";
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import { ContextHandler } from "../context-handler"; import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port"; import { getFreePort } from "../port";
@ -81,8 +80,7 @@ describe("create clusters", () => {
c = new Cluster({ c = new Cluster({
id: "foo", id: "foo",
contextName: "minikube", contextName: "minikube",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml"
workspace: workspaceStore.currentWorkspaceId
}); });
}); });
@ -162,8 +160,7 @@ describe("create clusters", () => {
}({ }({
id: "foo", id: "foo",
contextName: "minikube", contextName: "minikube",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml"
workspace: workspaceStore.currentWorkspaceId
}); });
await c.init(port); await c.init(port);

View File

@ -26,7 +26,6 @@ jest.mock("winston", () => ({
import { KubeconfigManager } from "../kubeconfig-manager"; import { KubeconfigManager } from "../kubeconfig-manager";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import { workspaceStore } from "../../common/workspace-store";
import { ContextHandler } from "../context-handler"; import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port"; import { getFreePort } from "../port";
import fse from "fs-extra"; import fse from "fs-extra";
@ -77,8 +76,7 @@ describe("kubeconfig manager tests", () => {
const cluster = new Cluster({ const cluster = new Cluster({
id: "foo", id: "foo",
contextName: "minikube", contextName: "minikube",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml"
workspace: workspaceStore.currentWorkspaceId
}); });
const contextHandler = new ContextHandler(cluster); const contextHandler = new ContextHandler(cluster);
const port = await getFreePort(); const port = await getFreePort();
@ -98,8 +96,7 @@ describe("kubeconfig manager tests", () => {
const cluster = new Cluster({ const cluster = new Cluster({
id: "foo", id: "foo",
contextName: "minikube", contextName: "minikube",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml"
workspace: workspaceStore.currentWorkspaceId
}); });
const contextHandler = new ContextHandler(cluster); const contextHandler = new ContextHandler(cluster);
const port = await getFreePort(); const port = await getFreePort();

View File

@ -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 }));
}
}

View File

@ -1,16 +1,25 @@
import "../common/cluster-ipc"; import "../common/cluster-ipc";
import type http from "http"; import type http from "http";
import { ipcMain } from "electron"; 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 { clusterStore, getClusterIdFromHost } from "../common/cluster-store";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import logger from "./logger"; import logger from "./logger";
import { apiKubePrefix } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import { Singleton } from "../common/utils"; 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 { export class ClusterManager extends Singleton {
@observable.deep catalogSource: CatalogEntity[] = [];
constructor(public readonly port: number) { constructor(public readonly port: number) {
super(); super();
catalogEntityRegistry.addSource("lens:kubernetes-clusters", this.catalogSource);
// auto-init clusters // auto-init clusters
reaction(() => clusterStore.enabledClustersList, (clusters) => { reaction(() => clusterStore.enabledClustersList, (clusters) => {
clusters.forEach((cluster) => { clusters.forEach((cluster) => {
@ -19,8 +28,18 @@ export class ClusterManager extends Singleton {
cluster.init(port); cluster.init(port);
} }
}); });
}, { fireImmediately: true }); }, { fireImmediately: true });
reaction(() => toJS(clusterStore.enabledClustersList, { recurseEverything: true }), () => {
this.updateCatalogSource(clusterStore.enabledClustersList);
}, { fireImmediately: true });
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => {
this.syncClustersFromCatalog(entities);
});
// auto-stop removed clusters // auto-stop removed clusters
autorun(() => { autorun(() => {
const removedClusters = Array.from(clusterStore.removedClusters.values()); const removedClusters = Array.from(clusterStore.removedClusters.values());
@ -40,6 +59,90 @@ export class ClusterManager extends Singleton {
ipcMain.on("network:online", () => { this.onNetworkOnline(); }); 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() { protected onNetworkOffline() {
logger.info("[CLUSTER-MANAGER]: network is offline"); logger.info("[CLUSTER-MANAGER]: network is offline");
clusterStore.enabledClustersList.forEach((cluster) => { clusterStore.enabledClustersList.forEach((cluster) => {

View File

@ -1,7 +1,6 @@
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store"; import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store";
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; 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 { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import { broadcastMessage, InvalidKubeconfigChannel, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; import { broadcastMessage, InvalidKubeconfigChannel, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
@ -104,18 +103,16 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable * @observable
*/ */
@observable contextName: string; @observable contextName: string;
/**
* Workspace id
*
* @observable
*/
@observable workspace: WorkspaceId;
/** /**
* Path to kubeconfig * Path to kubeconfig
* *
* @observable * @observable
*/ */
@observable kubeConfigPath: string; @observable kubeConfigPath: string;
/**
* @deprecated
*/
@observable workspace: string;
/** /**
* Kubernetes API server URL * Kubernetes API server URL
* *

View File

@ -17,7 +17,6 @@ import { registerFileProtocol } from "../common/register-protocol";
import logger from "./logger"; import logger from "./logger";
import { clusterStore } from "../common/cluster-store"; import { clusterStore } from "../common/cluster-store";
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import { extensionsStore } from "../extensions/extensions-store"; import { extensionsStore } from "../extensions/extensions-store";
@ -30,6 +29,9 @@ import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc"; import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater"; import { startUpdateChecking } from "./app-updater";
import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; 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); const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number; let proxyPort: number;
@ -107,7 +109,7 @@ app.on("ready", async () => {
await Promise.all([ await Promise.all([
userStore.load(), userStore.load(),
clusterStore.load(), clusterStore.load(),
workspaceStore.load(), hotbarStore.load(),
extensionsStore.load(), extensionsStore.load(),
filesystemProvisionerStore.load(), filesystemProvisionerStore.load(),
]); ]);
@ -164,6 +166,7 @@ app.on("ready", async () => {
} }
ipcMain.on(IpcRendererNavigationEvents.LOADED, () => { ipcMain.on(IpcRendererNavigationEvents.LOADED, () => {
CatalogPusher.init(catalogEntityRegistry);
startUpdateChecking(); startUpdateChecking();
LensProtocolRouterMain LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>() .getInstance<LensProtocolRouterMain>()

View File

@ -7,6 +7,7 @@ import { preferencesURL } from "../renderer/components/+preferences/preferences.
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route"; import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
import { extensionsURL } from "../renderer/components/+extensions/extensions.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 { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger"; import logger from "./logger";
import { exitApp } from "./exit-app"; import { exitApp } from "./exit-app";
@ -175,6 +176,13 @@ export function buildMenu(windowManager: WindowManager) {
const viewMenu: MenuItemConstructorOptions = { const viewMenu: MenuItemConstructorOptions = {
label: "View", label: "View",
submenu: [ submenu: [
{
label: "Catalog",
accelerator: "Shift+CmdOrCtrl+C",
click() {
navigate(catalogURL());
}
},
{ {
label: "Command Palette...", label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P", accelerator: "Shift+CmdOrCtrl+P",

View File

@ -5,10 +5,7 @@ import { autorun } from "mobx";
import { showAbout } from "./menu"; import { showAbout } from "./menu";
import { checkForUpdates } from "./app-updater"; import { checkForUpdates } from "./app-updater";
import { WindowManager } from "./window-manager"; 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 { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { clusterViewURL } from "../renderer/components/cluster-manager/cluster-view.route";
import logger from "./logger"; import logger from "./logger";
import { isDevelopment, isWindows } from "../common/vars"; import { isDevelopment, isWindows } from "../common/vars";
import { exitApp } from "./exit-app"; 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 })); .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", label: "Check for updates",
click() { click() {

View File

@ -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);
}
});

View File

@ -0,0 +1,7 @@
// Hotbar store migrations
import version500alpha0 from "./5.0.0-alpha.0";
export default {
...version500alpha0,
};

View File

@ -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");
});
});
});

View File

@ -145,10 +145,10 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po
describe("Pods", () => { describe("Pods", () => {
const podTests = []; const podTests = [];
for (let r = 0; r < 10; r += 1) { for (let r = 0; r < 3; r += 1) {
for (let d = 0; d < 10; d += 1) { for (let d = 0; d < 3; d += 1) {
for (let ir = 0; ir < 10; ir += 1) { for (let ir = 0; ir < 3; ir += 1) {
for (let id = 0; id < 10; id += 1) { for (let id = 0; id < 3; id += 1) {
podTests.push([r, d, ir, id]); podTests.push([r, d, ir, id]);
} }
} }

View File

@ -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<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
const items = this._items.filter((item) => item.apiVersion === apiVersion && item.kind === kind);
return items as T[];
}
getItemsForCategory<T extends CatalogEntity>(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);

View File

@ -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;
}
};

View File

@ -62,7 +62,9 @@ export interface IKubeResourceList {
} }
export interface IKubeApiCluster { export interface IKubeApiCluster {
id: string; metadata: {
uid: string;
}
} }
export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeClass: IKubeObjectConstructor<T>): KubeApi<T> { export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeClass: IKubeObjectConstructor<T>): KubeApi<T> {
@ -71,7 +73,7 @@ export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeC
debug: isDevelopment, debug: isDevelopment,
}, { }, {
headers: { headers: {
"X-Cluster-ID": cluster.id "X-Cluster-ID": cluster.metadata.uid
} }
}); });

View File

@ -10,11 +10,11 @@ import { clusterStore } from "../common/cluster-store";
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import { delay } from "../common/utils"; import { delay } from "../common/utils";
import { isMac, isDevelopment } from "../common/vars"; import { isMac, isDevelopment } from "../common/vars";
import { workspaceStore } from "../common/workspace-store";
import * as LensExtensions from "../extensions/extension-api"; import * as LensExtensions from "../extensions/extension-api";
import { extensionDiscovery } from "../extensions/extension-discovery"; import { extensionDiscovery } from "../extensions/extension-discovery";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import { extensionsStore } from "../extensions/extensions-store"; import { extensionsStore } from "../extensions/extensions-store";
import { hotbarStore } from "../common/hotbar-store";
import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { filesystemProvisionerStore } from "../main/extension-filesystem";
import { App } from "./components/app"; import { App } from "./components/app";
import { LensApp } from "./lens-app"; import { LensApp } from "./lens-app";
@ -56,7 +56,7 @@ export async function bootstrap(App: AppComponent) {
// preload common stores // preload common stores
await Promise.all([ await Promise.all([
userStore.load(), userStore.load(),
workspaceStore.load(), hotbarStore.load(),
clusterStore.load(), clusterStore.load(),
extensionsStore.load(), extensionsStore.load(),
filesystemProvisionerStore.load(), filesystemProvisionerStore.load(),
@ -65,7 +65,6 @@ export async function bootstrap(App: AppComponent) {
// Register additional store listeners // Register additional store listeners
clusterStore.registerIpcListener(); clusterStore.registerIpcListener();
workspaceStore.registerIpcListener();
// init app's dependencies if any // init app's dependencies if any
if (App.init) { if (App.init) {
@ -74,7 +73,6 @@ export async function bootstrap(App: AppComponent) {
window.addEventListener("message", (ev: MessageEvent) => { window.addEventListener("message", (ev: MessageEvent) => {
if (ev.data === "teardown") { if (ev.data === "teardown") {
userStore.unregisterIpcListener(); userStore.unregisterIpcListener();
workspaceStore.unregisterIpcListener();
clusterStore.unregisterIpcListener(); clusterStore.unregisterIpcListener();
unmountComponentAtNode(rootElem); unmountComponentAtNode(rootElem);
window.location.href = "about:blank"; window.location.href = "about:blank";

View File

@ -12,11 +12,9 @@ import { Button } from "../button";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store"; import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { userStore } from "../../../common/user-store"; import { userStore } from "../../../common/user-store";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Tab, Tabs } from "../tabs"; import { Tab, Tabs } from "../tabs";
@ -24,6 +22,7 @@ import { ExecValidationNotFoundError } from "../../../common/custom-errors";
import { appEventBus } from "../../../common/event-bus"; import { appEventBus } from "../../../common/event-bus";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { docsUrl } from "../../../common/vars"; import { docsUrl } from "../../../common/vars";
import { catalogURL } from "../+catalog";
enum KubeConfigSourceTab { enum KubeConfigSourceTab {
FILE = "file", FILE = "file",
@ -171,7 +170,6 @@ export class AddCluster extends React.Component {
return { return {
id: clusterId, id: clusterId,
kubeConfigPath, kubeConfigPath,
workspace: workspaceStore.currentWorkspaceId,
contextName: kubeConfig.currentContext, contextName: kubeConfig.currentContext,
preferences: { preferences: {
clusterName: kubeConfig.currentContext, clusterName: kubeConfig.currentContext,
@ -183,18 +181,11 @@ export class AddCluster extends React.Component {
runInAction(() => { runInAction(() => {
clusterStore.addClusters(...newClusters); clusterStore.addClusters(...newClusters);
if (newClusters.length === 1) { Notifications.ok(
const clusterId = newClusters[0].id; <>Successfully imported <b>{newClusters.length}</b> cluster(s)</>
);
clusterStore.setActive(clusterId); navigate(catalogURL());
navigate(clusterViewURL({ params: { clusterId } }));
} else {
if (newClusters.length > 1) {
Notifications.ok(
<>Successfully imported <b>{newClusters.length}</b> cluster(s)</>
);
}
}
}); });
this.refreshContexts(); this.refreshContexts();
} catch (err) { } catch (err) {

View File

@ -6,13 +6,13 @@ import { releaseURL } from "../+apps-releases";
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewHelmCharts", id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts", title: "Cluster: View Helm Charts",
scope: "cluster", scope: "entity",
action: () => navigate(helmChartsURL()) action: () => navigate(helmChartsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewHelmReleases", id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases", title: "Cluster: View Helm Releases",
scope: "cluster", scope: "entity",
action: () => navigate(releaseURL()) action: () => navigate(releaseURL())
}); });

View File

@ -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<CatalogEntityItem> {
@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);
}
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, {
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 (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="header">Catalog</div>
{ this.categories.map((category, index) => {
return <Tab value={category.getId()} key={index} label={category.metadata.name} data-testid={`${category.getId()}-tab`} />;
})}
</Tabs>
);
}
@autobind()
renderItemMenu(item: CatalogEntityItem) {
const onOpen = async () => {
await item.onContextMenuOpen(this.contextMenu);
};
return (
<MenuActions onOpen={() => onOpen()}>
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(item) }>
<Icon material="add" small interactive={true} title="Add to hotbar"/> Add to Hotbar
</MenuItem>
<MenuItem key="remove-from-hotbar" onClick={() => this.removeFromHotbar(item) }>
<Icon material="clear" small interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{ this.contextMenu.menuItems.map((menuItem, index) => {
return (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title}/> {menuItem.title}
</MenuItem>
);
})}
</MenuActions>
);
}
render() {
if (!this.catalogEntityStore) {
return null;
}
return (
<PageLayout
className="CatalogPage"
navigation={this.renderNavigation()}
provideBackButtonNavigation={false}
contentGaps={false}>
<ItemListLayout
isClusterScoped
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
tableId="catalog-items"
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem) => item.name,
[sortBy.source]: (item: CatalogEntityItem) => item.source,
[sortBy.status]: (item: CatalogEntityItem) => item.phase,
}}
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) => <Badge key={label} label={label} title={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()),
}}
/>
</PageLayout>
);
}
}

View File

@ -0,0 +1,2 @@
export * from "./catalog.route";
export * from "./catalog";

View File

@ -12,5 +12,5 @@ commandRegistry.add({
clusterId: clusterStore.active.id clusterId: clusterStore.active.id
} }
})), })),
isActive: (context) => !!context.cluster isActive: (context) => !!context.entity
}); });

View File

@ -4,12 +4,9 @@ import React from "react";
import { reaction } from "mobx"; import { reaction } from "mobx";
import { RouteComponentProps } from "react-router"; import { RouteComponentProps } from "react-router";
import { observer, disposeOnUnmount } from "mobx-react"; import { observer, disposeOnUnmount } from "mobx-react";
import { Features } from "./features";
import { Removal } from "./removal";
import { Status } from "./status"; import { Status } from "./status";
import { General } from "./general"; import { General } from "./general";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { ClusterIcon } from "../cluster-icon";
import { IClusterSettingsRouteParams } from "./cluster-settings.route"; import { IClusterSettingsRouteParams } from "./cluster-settings.route";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
@ -58,7 +55,6 @@ export class ClusterSettings extends React.Component<Props> {
if (!cluster) return null; if (!cluster) return null;
const header = ( const header = (
<> <>
<ClusterIcon cluster={cluster} showErrors={false} showTooltip={false}/>
<h2>{cluster.preferences.clusterName}</h2> <h2>{cluster.preferences.clusterName}</h2>
</> </>
); );
@ -67,8 +63,6 @@ export class ClusterSettings extends React.Component<Props> {
<PageLayout className="ClusterSettings" header={header} showOnTop={true}> <PageLayout className="ClusterSettings" header={header} showOnTop={true}>
<Status cluster={cluster}></Status> <Status cluster={cluster}></Status>
<General cluster={cluster}></General> <General cluster={cluster}></General>
<Features cluster={cluster}></Features>
<Removal cluster={cluster}></Removal>
</PageLayout> </PageLayout>
); );
} }

View File

@ -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<Props> {
@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 <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>;
}
}
render() {
const label = (
<>
<ClusterIcon
cluster={this.props.cluster}
showErrors={false}
showTooltip={false}
/>
{"Browse for new icon..."}
</>
);
return (
<>
<SubTitle title="Cluster Icon" />
<p>Define cluster icon. By default automatically generated.</p>
<div className="file-loader">
<FilePicker
accept="image/*"
label={label}
onOverSizeLimit={OverSizeLimitStyle.FILTER}
handler={this.onIconPick}
/>
{this.getClearButton()}
</div>
</>
);
}
}

View File

@ -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<Props> {
render() {
return (
<>
<SubTitle title="Cluster Workspace"/>
<p>
Define cluster workspace.
</p>
<Select
value={this.props.cluster.workspace}
onChange={({value}) => this.props.cluster.workspace = value}
options={workspaceStore.enabledWorkspacesList.map(w =>
({value: w.id, label: w.name})
)}
/>
</>
);
}
}

View File

@ -1,110 +0,0 @@
import React from "react";
import { observable, reaction, comparer } from "mobx";
import { observer, disposeOnUnmount } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { Notifications } from "../../notifications";
import { Spinner } from "../../spinner";
import { ClusterFeature } from "../../../../extensions/cluster-feature";
import { interval } from "../../../utils";
interface Props {
cluster: Cluster
feature: ClusterFeature
}
@observer
export class InstallFeature extends React.Component<Props> {
@observable loading = false;
@observable message = "";
componentDidMount() {
const feature = this.props.feature;
const cluster = this.props.cluster;
const statusUpdate = interval(20, () => {
feature.updateStatus(cluster);
});
statusUpdate.start(true);
disposeOnUnmount(this, () => {
statusUpdate.stop();
});
disposeOnUnmount(this,
reaction(() => feature.status.installed, () => {
this.loading = false;
this.message = "";
}, { equals: comparer.structural })
);
}
getActionButtons() {
const { cluster, feature } = this.props;
const disabled = !cluster.isAdmin || this.loading;
const loadingIcon = this.loading ? <Spinner/> : null;
return (
<div className="flex gaps align-center">
{feature.status.canUpgrade &&
<Button
primary
disabled={disabled}
onClick={this.runAction(() =>
feature.upgrade(cluster)
)}
>
Upgrade
</Button>
}
{feature.status.installed &&
<Button
accent
disabled={disabled}
onClick={this.runAction(async () => {
this.message = "Uninstalling feature ...";
feature.uninstall(cluster);
})}
>
Uninstall
</Button>
}
{!feature.status.installed && !feature.status.canUpgrade &&
<Button
primary
disabled={disabled}
onClick={this.runAction(async () =>{
this.message = "Installing feature ...";
feature.install(cluster);
})}
>
Install
</Button>
}
{loadingIcon}
{!cluster.isAdmin && <span className='admin-note'>Actions can only be performed by admins.</span>}
{cluster.isAdmin && this.loading && this.message !== "" && <span className='admin-note'>{this.message}</span>}
</div>
);
}
runAction(action: () => Promise<any>): () => Promise<void> {
return async () => {
try {
this.loading = true;
await action();
} catch (err) {
Notifications.error(err.toString());
}
};
}
render() {
return (
<>
{this.props.children}
<div className="button-area">{this.getActionButtons()}</div>
</>
);
}
}

View File

@ -1,33 +0,0 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { InstallFeature } from "./components/install-feature";
import { SubTitle } from "../layout/sub-title";
import { clusterFeatureRegistry } from "../../../extensions/registries/cluster-feature-registry";
interface Props {
cluster: Cluster;
}
export class Features extends React.Component<Props> {
render() {
const { cluster } = this.props;
return (
<div>
<h2>Features</h2>
{
clusterFeatureRegistry
.getItems()
.map((f) => (
<InstallFeature key={f.title} cluster={cluster} feature={f.feature}>
<>
<SubTitle title={f.title} />
<p><f.components.Description /></p>
</>
</InstallFeature>
))
}
</div>
);
}
}

View File

@ -1,8 +1,6 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { ClusterNameSetting } from "./components/cluster-name-setting"; import { ClusterNameSetting } from "./components/cluster-name-setting";
import { ClusterWorkspaceSetting } from "./components/cluster-workspace-setting";
import { ClusterIconSetting } from "./components/cluster-icon-setting";
import { ClusterProxySetting } from "./components/cluster-proxy-setting"; import { ClusterProxySetting } from "./components/cluster-proxy-setting";
import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting"; import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting";
import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting"; import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting";
@ -19,8 +17,6 @@ export class General extends React.Component<Props> {
return <div> return <div>
<h2>General</h2> <h2>General</h2>
<ClusterNameSetting cluster={this.props.cluster} /> <ClusterNameSetting cluster={this.props.cluster} />
<ClusterWorkspaceSetting cluster={this.props.cluster} />
<ClusterIconSetting cluster={this.props.cluster} />
<ClusterProxySetting cluster={this.props.cluster} /> <ClusterProxySetting cluster={this.props.cluster} />
<ClusterPrometheusSetting cluster={this.props.cluster} /> <ClusterPrometheusSetting cluster={this.props.cluster} />
<ClusterHomeDirSetting cluster={this.props.cluster} /> <ClusterHomeDirSetting cluster={this.props.cluster} />

View File

@ -1,20 +0,0 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { RemoveClusterButton } from "./components/remove-cluster-button";
interface Props {
cluster: Cluster;
}
export class Removal extends React.Component<Props> {
render() {
const { cluster } = this.props;
return (
<div>
<h2>Removal</h2>
<RemoveClusterButton cluster={cluster} />
</div>
);
}
}

View File

@ -10,41 +10,41 @@ import { pdbURL } from "../+config-pod-disruption-budgets";
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewConfigMaps", id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps", title: "Cluster: View ConfigMaps",
scope: "cluster", scope: "entity",
action: () => navigate(configMapsURL()) action: () => navigate(configMapsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewSecrets", id: "cluster.viewSecrets",
title: "Cluster: View Secrets", title: "Cluster: View Secrets",
scope: "cluster", scope: "entity",
action: () => navigate(secretsURL()) action: () => navigate(secretsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewResourceQuotas", id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas", title: "Cluster: View ResourceQuotas",
scope: "cluster", scope: "entity",
action: () => navigate(resourceQuotaURL()) action: () => navigate(resourceQuotaURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewLimitRanges", id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges", title: "Cluster: View LimitRanges",
scope: "cluster", scope: "entity",
action: () => navigate(limitRangeURL()) action: () => navigate(limitRangeURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewHorizontalPodAutoscalers", id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)", title: "Cluster: View HorizontalPodAutoscalers (HPA)",
scope: "cluster", scope: "entity",
action: () => navigate(hpaURL()) action: () => navigate(hpaURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewPodDisruptionBudget", id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets", title: "Cluster: View PodDisruptionBudgets",
scope: "cluster", scope: "entity",
action: () => navigate(pdbURL()) action: () => navigate(pdbURL())
}); });

View File

@ -1,2 +0,0 @@
export * from "./landing-page.route";
export * from "./landing-page";

View File

@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const landingRoute: RouteProps = {
path: "/landing"
};
export const landingURL = buildURL(landingRoute.path);

View File

@ -1,14 +0,0 @@
.PageLayout.LandingOverview {
--width: 100%;
--height: 100%;
text-align: center;
bottom: 22px; // Making bottom bar visible
.content-wrapper {
.content {
margin: unset;
max-width: unset;
height: 100%;
}
}
}

View File

@ -1,43 +0,0 @@
import "./landing-page.scss";
import React from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { WorkspaceOverview } from "./workspace-overview";
import { PageLayout } from "../layout/page-layout";
import { Notifications } from "../notifications";
import { Icon } from "../icon";
@observer
export class LandingPage extends React.Component {
@observable showHint = true;
@computed
get clusters() {
return clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
}
componentDidMount() {
const noClustersInScope = !this.clusters.length;
const showStartupHint = this.showHint;
if (showStartupHint && noClustersInScope) {
Notifications.info(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, {
timeout: 30_000,
id: "landing-welcome"
});
}
}
render() {
const showBackButton = this.clusters.length > 0;
const header = <><Icon svg="logo-lens" big /> <h2>{workspaceStore.currentWorkspace.name}</h2></>;
return (
<PageLayout className="LandingOverview flex" header={header} provideBackButtonNavigation={showBackButton} showOnTop={true}>
<WorkspaceOverview />
</PageLayout>
);
}
}

View File

@ -1,74 +0,0 @@
import React from "react";
import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store";
import { autobind, cssNames } from "../../utils";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { Workspace } from "../../../common/workspace-store";
import { clusterSettingsURL } from "../+cluster-settings";
import { navigate } from "../../navigation";
interface Props extends MenuActionsProps {
clusterItem: ClusterItem;
workspace: Workspace;
workspaceClusterStore: WorkspaceClusterStore;
}
export class WorkspaceClusterMenu extends React.Component<Props> {
@autobind()
remove() {
const { clusterItem, workspaceClusterStore } = this.props;
return workspaceClusterStore.remove(clusterItem);
}
@autobind()
gotoSettings() {
const { clusterItem } = this.props;
navigate(clusterSettingsURL({
params: {
clusterId: clusterItem.id
}
}));
}
@autobind()
renderRemoveMessage() {
const { clusterItem, workspace } = this.props;
return (
<p>Remove cluster <b>{clusterItem.name}</b> from workspace <b>{workspace.name}</b>?</p>
);
}
renderContent() {
const { toolbar } = this.props;
return (
<>
<MenuItem onClick={this.gotoSettings}>
<Icon material="settings" interactive={toolbar} title="Settings"/>
<span className="title">Settings</span>
</MenuItem>
</>
);
}
render() {
const { clusterItem: { cluster: { isManaged } }, className, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("WorkspaceClusterMenu", className)}
removeAction={isManaged ? null : this.remove}
removeConfirmationMessage={this.renderRemoveMessage}
>
{this.renderContent()}
</MenuActions>
);
}
}

View File

@ -1,79 +0,0 @@
import { WorkspaceId } from "../../../common/workspace-store";
import { Cluster } from "../../../main/cluster";
import { clusterStore } from "../../../common/cluster-store";
import { ItemObject, ItemStore } from "../../item.store";
import { autobind } from "../../utils";
import { computed, reaction } from "mobx";
export class ClusterItem implements ItemObject {
constructor(public cluster: Cluster) {}
get name() {
return this.cluster.name;
}
get distribution() {
return this.cluster.metadata?.distribution?.toString() ?? "unknown";
}
get version() {
return this.cluster.version;
}
get connectionStatus() {
return this.cluster.online ? "connected" : "disconnected";
}
getName() {
return this.name;
}
get id() {
return this.cluster.id;
}
get clusterId() {
return this.cluster.id;
}
getId() {
return this.id;
}
}
/** an ItemStore of the clusters belonging to a given workspace */
@autobind()
export class WorkspaceClusterStore extends ItemStore<ClusterItem> {
workspaceId: WorkspaceId;
constructor(workspaceId: WorkspaceId) {
super();
this.workspaceId = workspaceId;
}
@computed get clusters(): ClusterItem[] {
return clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster));
}
watch() {
return reaction(() => this.clusters, () => this.loadAll(), {
fireImmediately: true
});
}
loadAll() {
return this.loadItems(() => this.clusters);
}
async remove(clusterItem: ClusterItem) {
const { cluster: { isManaged, id: clusterId }} = clusterItem;
if (!isManaged) {
return super.removeItem(clusterItem, () => clusterStore.removeById(clusterId));
}
}
}

View File

@ -1,32 +0,0 @@
.WorkspaceOverview {
max-height: 50%;
.Table {
padding-bottom: 60px;
}
.TableCell {
display: flex;
align-items: left;
&.cluster-icon {
align-items: center;
flex-grow: 0.2;
padding: 0;
}
&.connected {
color: var(--colorSuccess);
}
}
.TableCell.status {
flex: 0.1;
}
.TableCell.distribution {
flex: 0.2;
}
.TableCell.version {
flex: 0.2;
}
}

View File

@ -1,92 +0,0 @@
import "./workspace-overview.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store";
import { navigate } from "../../navigation";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { WorkspaceClusterMenu } from "./workspace-cluster-menu";
import { kebabCase } from "lodash";
import { addClusterURL } from "../+add-cluster";
import { IReactionDisposer, observable, reaction } from "mobx";
import { workspaceStore } from "../../../common/workspace-store";
enum sortBy {
name = "name",
distribution = "distribution",
version = "version",
online = "online"
}
@observer
export class WorkspaceOverview extends Component {
@observable private workspaceClusterStore?: WorkspaceClusterStore;
disposeWorkspaceWatch: IReactionDisposer;
disposeClustersWatch: IReactionDisposer;
componentDidMount() {
this.disposeWorkspaceWatch = reaction(() => workspaceStore.currentWorkspaceId, workspaceId => {
this.workspaceClusterStore = new WorkspaceClusterStore(workspaceId);
this.disposeClustersWatch?.();
this.disposeClustersWatch = this.workspaceClusterStore.watch();
}, {
fireImmediately: true,
});
}
componentWillUnmount() {
this.disposeWorkspaceWatch?.();
this.disposeClustersWatch?.();
}
showCluster = ({ clusterId }: ClusterItem) => {
navigate(clusterViewURL({ params: { clusterId } }));
};
render() {
const { workspaceClusterStore } = this;
if (!workspaceClusterStore) {
return null;
}
return (
<ItemListLayout
renderHeaderTitle="Clusters"
isClusterScoped
isSearchable={false}
isSelectable={false}
className="WorkspaceOverview"
store={workspaceClusterStore}
sortingCallbacks={{
[sortBy.name]: (item: ClusterItem) => item.name,
[sortBy.distribution]: (item: ClusterItem) => item.distribution,
[sortBy.version]: (item: ClusterItem) => item.version,
[sortBy.online]: (item: ClusterItem) => item.connectionStatus,
}}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Distribution", className: "distribution", sortBy: sortBy.distribution },
{ title: "Version", className: "version", sortBy: sortBy.version },
{ title: "Status", className: "status", sortBy: sortBy.online },
]}
renderTableContents={(item: ClusterItem) => [
item.name,
item.distribution,
item.version,
{ title: item.connectionStatus, className: kebabCase(item.connectionStatus) }
]}
onDetails={this.showCluster}
addRemoveButtons={{
addTooltip: "Add Cluster",
onAdd: () => navigate(addClusterURL()),
}}
renderItemMenu={(clusterItem: ClusterItem) => (
<WorkspaceClusterMenu clusterItem={clusterItem} workspace={workspaceStore.currentWorkspace} workspaceClusterStore={workspaceClusterStore}/>
)}
/>
);
}
}

View File

@ -8,27 +8,27 @@ import { networkPoliciesURL } from "../+network-policies";
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewServices", id: "cluster.viewServices",
title: "Cluster: View Services", title: "Cluster: View Services",
scope: "cluster", scope: "entity",
action: () => navigate(servicesURL()) action: () => navigate(servicesURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewEndpoints", id: "cluster.viewEndpoints",
title: "Cluster: View Endpoints", title: "Cluster: View Endpoints",
scope: "cluster", scope: "entity",
action: () => navigate(endpointURL()) action: () => navigate(endpointURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewIngresses", id: "cluster.viewIngresses",
title: "Cluster: View Ingresses", title: "Cluster: View Ingresses",
scope: "cluster", scope: "entity",
action: () => navigate(ingressURL()) action: () => navigate(ingressURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewNetworkPolicies", id: "cluster.viewNetworkPolicies",
title: "Cluster: View NetworkPolicies", title: "Cluster: View NetworkPolicies",
scope: "cluster", scope: "entity",
action: () => navigate(networkPoliciesURL()) action: () => navigate(networkPoliciesURL())
}); });

View File

@ -5,6 +5,6 @@ import { nodesURL } from "./nodes.route";
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewNodes", id: "cluster.viewNodes",
title: "Cluster: View Nodes", title: "Cluster: View Nodes",
scope: "cluster", scope: "entity",
action: () => navigate(nodesURL()) action: () => navigate(nodesURL())
}); });

View File

@ -5,41 +5,41 @@ import { cronJobsURL, daemonSetsURL, deploymentsURL, jobsURL, podsURL, statefulS
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewPods", id: "cluster.viewPods",
title: "Cluster: View Pods", title: "Cluster: View Pods",
scope: "cluster", scope: "entity",
action: () => navigate(podsURL()) action: () => navigate(podsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewDeployments", id: "cluster.viewDeployments",
title: "Cluster: View Deployments", title: "Cluster: View Deployments",
scope: "cluster", scope: "entity",
action: () => navigate(deploymentsURL()) action: () => navigate(deploymentsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewDaemonSets", id: "cluster.viewDaemonSets",
title: "Cluster: View DaemonSets", title: "Cluster: View DaemonSets",
scope: "cluster", scope: "entity",
action: () => navigate(daemonSetsURL()) action: () => navigate(daemonSetsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewStatefulSets", id: "cluster.viewStatefulSets",
title: "Cluster: View StatefulSets", title: "Cluster: View StatefulSets",
scope: "cluster", scope: "entity",
action: () => navigate(statefulSetsURL()) action: () => navigate(statefulSetsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewJobs", id: "cluster.viewJobs",
title: "Cluster: View Jobs", title: "Cluster: View Jobs",
scope: "cluster", scope: "entity",
action: () => navigate(jobsURL()) action: () => navigate(jobsURL())
}); });
commandRegistry.add({ commandRegistry.add({
id: "cluster.viewCronJobs", id: "cluster.viewCronJobs",
title: "Cluster: View CronJobs", title: "Cluster: View CronJobs",
scope: "cluster", scope: "entity",
action: () => navigate(cronJobsURL()) action: () => navigate(cronJobsURL())
}); });

View File

@ -1,64 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { Workspace, workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Input, InputValidator } from "../input";
import { navigate } from "../../navigation";
import { CommandOverlay } from "../command-palette/command-container";
import { landingURL } from "../+landing-page";
import { clusterStore } from "../../../common/cluster-store";
const uniqueWorkspaceName: InputValidator = {
condition: ({ required }) => required,
message: () => `Workspace with this name already exists`,
validate: value => !workspaceStore.getByName(value),
};
@observer
export class AddWorkspace extends React.Component {
onSubmit(name: string) {
if (!name.trim()) {
return;
}
const workspace = workspaceStore.addWorkspace(new Workspace({
id: uuid(),
name
}));
if (!workspace) {
return;
}
workspaceStore.setActive(workspace.id);
clusterStore.setActive(null);
navigate(landingURL());
CommandOverlay.close();
}
render() {
return (
<>
<Input
placeholder="Workspace name"
autoFocus={true}
theme="round-black"
data-test-id="command-palette-workspace-add-name"
validators={[uniqueWorkspaceName]}
onSubmit={(v) => this.onSubmit(v)}
dirty={true}
showValidationLine={true} />
<small className="hint">
Please provide a new workspace name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small>
</>
);
}
}
commandRegistry.add({
id: "workspace.addWorkspace",
title: "Workspace: Add workspace ...",
scope: "global",
action: () => CommandOverlay.open(<AddWorkspace />)
});

View File

@ -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 (
<>
<Input
placeholder="Workspace name"
autoFocus={true}
theme="round-black"
data-test-id="command-palette-workspace-add-name"
validators={[validateWorkspaceName]}
onChange={(v) => this.onChange(v)}
onSubmit={(v) => this.onSubmit(v)}
dirty={true}
value={this.name}
showValidationLine={true} />
<small className="hint">
Please provide a new workspace name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small>
</>
);
}
}
commandRegistry.add({
id: "workspace.editCurrentWorkspace",
title: "Workspace: Edit current workspace ...",
scope: "global",
action: () => CommandOverlay.open(<EditWorkspace />),
isActive: (context) => context.workspace?.id !== WorkspaceStore.defaultId
});

View File

@ -1 +0,0 @@
export * from "./workspaces";

View File

@ -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: (
<div className="confirm flex column gaps">
<p>
Are you sure you want remove workspace <b>{workspace.name}</b>?
</p>
<p className="info">
All clusters within workspace will be cleared as well
</p>
</div>
),
});
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
data-test-id="command-palette-workspace-remove-select"
placeholder="Remove workspace" />
);
}
}
commandRegistry.add({
id: "workspace.removeWorkspace",
title: "Workspace: Remove workspace ...",
scope: "global",
action: () => CommandOverlay.open(<RemoveWorkspace />)
});

View File

@ -1,99 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { computed} from "mobx";
import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Select } from "../select";
import { navigate } from "../../navigation";
import { CommandOverlay } from "../command-palette/command-container";
import { AddWorkspace } from "./add-workspace";
import { RemoveWorkspace } from "./remove-workspace";
import { EditWorkspace } from "./edit-workspace";
import { landingURL } from "../+landing-page";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer
export class ChooseWorkspace extends React.Component {
private static overviewActionId = "__overview__";
private static addActionId = "__add__";
private static removeActionId = "__remove__";
private static editActionId = "__edit__";
@computed get options() {
const options = workspaceStore.enabledWorkspacesList.map((workspace) => {
return { value: workspace.id, label: workspace.name };
});
options.push({ value: ChooseWorkspace.overviewActionId, label: "Show current workspace overview ..." });
options.push({ value: ChooseWorkspace.addActionId, label: "Add workspace ..." });
if (options.length > 1) {
options.push({ value: ChooseWorkspace.removeActionId, label: "Remove workspace ..." });
if (workspaceStore.currentWorkspace.id !== WorkspaceStore.defaultId) {
options.push({ value: ChooseWorkspace.editActionId, label: "Edit current workspace ..." });
}
}
return options;
}
onChange(id: string) {
if (id === ChooseWorkspace.overviewActionId) {
navigate(landingURL()); // overview of active workspace. TODO: change name from landing
CommandOverlay.close();
return;
}
if (id === ChooseWorkspace.addActionId) {
CommandOverlay.open(<AddWorkspace />);
return;
}
if (id === ChooseWorkspace.removeActionId) {
CommandOverlay.open(<RemoveWorkspace />);
return;
}
if (id === ChooseWorkspace.editActionId) {
CommandOverlay.open(<EditWorkspace />);
return;
}
workspaceStore.setActive(id);
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
if (clusterId) {
navigate(clusterViewURL({ params: { clusterId } }));
} else {
navigate(landingURL());
}
CommandOverlay.close();
}
render() {
return (
<Select
onChange={(v) => 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(<ChooseWorkspace />)
});

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { computed, observable, reaction } from "mobx"; import { observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Redirect, Route, Router, Switch } from "react-router"; import { Redirect, Route, Router, Switch } from "react-router";
import { history } from "../navigation"; import { history } from "../navigation";
@ -36,7 +36,7 @@ import { webFrame } from "electron";
import { clusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry"; import { clusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry";
import { extensionLoader } from "../../extensions/extension-loader"; import { extensionLoader } from "../../extensions/extension-loader";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import { broadcastMessage, requestMain } from "../../common/ipc"; import { requestMain } from "../../common/ipc";
import whatInput from "what-input"; import whatInput from "what-input";
import { clusterSetFrameIdHandler } from "../../common/cluster-ipc"; import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries";
@ -86,20 +86,12 @@ export class App extends React.Component {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([podsStore, nodesStore, eventStore], { kubeWatchApi.subscribeStores([podsStore, nodesStore, eventStore], {
preload: true, preload: true,
}), })
reaction(() => this.warningsTotal, (count: number) => {
broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count);
}),
]); ]);
} }
@observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL(); @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL();
@computed get warningsTotal(): number {
return nodesStore.getWarningsCount() + eventStore.getWarningsCount();
}
getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) {
const routes: TabLayoutRoute[] = []; const routes: TabLayoutRoute[] = [];

View File

@ -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;
}
}

View File

@ -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<HTMLElement> {
cluster: Cluster;
className?: IClassName;
errorClass?: IClassName;
showErrors?: boolean;
showTooltip?: boolean;
interactive?: boolean;
isActive?: boolean;
options?: HashiconParams;
}
const defaultProps: Partial<Props> = {
showErrors: true,
showTooltip: true,
};
@observer
export class ClusterIcon extends React.Component<Props> {
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 (
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
{showTooltip && (
<Tooltip targetId={clusterIconId}>{name}</Tooltip>
)}
{icon && <img src={icon} alt={name}/>}
{!icon && <Hashicon value={clusterId} options={options}/>}
{showErrors && eventCount > 0 && !isActive && online && (
<Badge
className={cssNames("events-count", errorClass)}
label={eventCount >= 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount}
/>
)}
{children}
</div>
);
}
}

View File

@ -1 +0,0 @@
export * from "./cluster-icon";

View File

@ -6,7 +6,7 @@
padding: 0 2px; padding: 0 2px;
height: var(--bottom-bar-height); height: var(--bottom-bar-height);
#current-workspace { #catalog-link {
font-size: var(--font-size-small); font-size: var(--font-size-small);
color: white; color: white;
padding: $padding / 4 $padding / 2; padding: $padding / 4 $padding / 2;

View File

@ -2,11 +2,10 @@ import "./bottom-bar.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Icon } from "../icon";
import { workspaceStore } from "../../../common/workspace-store";
import { StatusBarRegistration, statusBarRegistry } from "../../../extensions/registries"; import { StatusBarRegistration, statusBarRegistry } from "../../../extensions/registries";
import { CommandOverlay } from "../command-palette/command-container"; import { navigate } from "../../navigation";
import { ChooseWorkspace } from "../+workspaces"; import { catalogURL } from "../+catalog";
import { Icon } from "../icon";
@observer @observer
export class BottomBar extends React.Component { export class BottomBar extends React.Component {
@ -45,13 +44,11 @@ export class BottomBar extends React.Component {
} }
render() { render() {
const { currentWorkspace } = workspaceStore;
return ( return (
<div className="BottomBar flex gaps"> <div className="BottomBar flex gaps">
<div id="current-workspace" data-test-id="current-workspace" className="flex gaps align-center" onClick={() => CommandOverlay.open(<ChooseWorkspace />)}> <div id="catalog-link" data-test-id="catalog-link" className="flex gaps align-center" onClick={() => navigate(catalogURL())}>
<Icon smallest material="layers"/> <Icon smallest material="view_list"/>
<span className="workspace-name" data-test-id="current-workspace-name">{currentWorkspace.name}</span> <span className="workspace-name" data-test-id="current-workspace-name">Catalog</span>
</div> </div>
{this.renderRegisteredItems()} {this.renderRegisteredItems()}
</div> </div>

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import { clusterSettingsURL } from "../+cluster-settings"; import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page"; import { catalogURL } from "../+catalog";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { broadcastMessage, requestMain } from "../../../common/ipc"; import { broadcastMessage, requestMain } from "../../../common/ipc";
@ -25,7 +25,7 @@ export const ClusterActions = (cluster: Cluster) => ({
})), })),
disconnect: async () => { disconnect: async () => {
clusterStore.deactivate(cluster.id); clusterStore.deactivate(cluster.id);
navigate(landingURL()); navigate(catalogURL());
await requestMain(clusterDisconnectHandler, cluster.id); await requestMain(clusterDisconnectHandler, cluster.id);
}, },
remove: () => { remove: () => {
@ -40,7 +40,7 @@ export const ClusterActions = (cluster: Cluster) => ({
ok: () => { ok: () => {
clusterStore.deactivate(cluster.id); clusterStore.deactivate(cluster.id);
clusterStore.removeById(cluster.id); clusterStore.removeById(cluster.id);
navigate(landingURL()); navigate(catalogURL());
}, },
message: <p> message: <p>
Are you sure want to remove cluster <b id={tooltipId}>{cluster.name}</b>? Are you sure want to remove cluster <b id={tooltipId}>{cluster.name}</b>?

View File

@ -13,7 +13,7 @@
display: flex; display: flex;
} }
.ClustersMenu { .HotbarMenu {
grid-area: menu; grid-area: menu;
} }
@ -34,4 +34,4 @@
flex: 1; flex: 1;
} }
} }
} }

View File

@ -4,19 +4,19 @@ import React from "react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { comparer, reaction } from "mobx"; import { comparer, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { ClustersMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar"; import { BottomBar } from "./bottom-bar";
import { LandingPage, landingRoute, landingURL } from "../+landing-page"; import { Catalog, catalogRoute, catalogURL } from "../+catalog";
import { Preferences, preferencesRoute } from "../+preferences"; import { Preferences, preferencesRoute } from "../+preferences";
import { AddCluster, addClusterRoute } from "../+add-cluster"; import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterView } from "./cluster-view"; import { ClusterView } from "./cluster-view";
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings"; 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 { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
import { globalPageRegistry } from "../../../extensions/registries/page-registry"; import { globalPageRegistry } from "../../../extensions/registries/page-registry";
import { Extensions, extensionsRoute } from "../+extensions"; import { Extensions, extensionsRoute } from "../+extensions";
import { getMatchedClusterId } from "../../navigation"; import { getMatchedClusterId } from "../../navigation";
import { HotbarMenu } from "../hotbar/hotbar-menu";
@observer @observer
export class ClusterManager extends React.Component { export class ClusterManager extends React.Component {
@ -44,17 +44,7 @@ export class ClusterManager extends React.Component {
} }
get startUrl() { get startUrl() {
const { activeClusterId } = clusterStore; return catalogURL();
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
});
}
return landingURL();
} }
render() { render() {
@ -63,7 +53,7 @@ export class ClusterManager extends React.Component {
<main> <main>
<div id="lens-views"/> <div id="lens-views"/>
<Switch> <Switch>
<Route component={LandingPage} {...landingRoute} /> <Route component={Catalog} {...catalogRoute} />
<Route component={Preferences} {...preferencesRoute} /> <Route component={Preferences} {...preferencesRoute} />
<Route component={Extensions} {...extensionsRoute} /> <Route component={Extensions} {...extensionsRoute} />
<Route component={AddCluster} {...addClusterRoute} /> <Route component={AddCluster} {...addClusterRoute} />
@ -75,7 +65,7 @@ export class ClusterManager extends React.Component {
<Redirect exact to={this.startUrl}/> <Redirect exact to={this.startUrl}/>
</Switch> </Switch>
</main> </main>
<ClustersMenu/> <HotbarMenu/>
<BottomBar/> <BottomBar/>
</div> </div>
); );

View File

@ -8,6 +8,8 @@ import { ClusterStatus } from "./cluster-status";
import { hasLoadedView } from "./lens-views"; import { hasLoadedView } from "./lens-views";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { navigate } from "../../navigation";
import { catalogURL } from "../+catalog";
interface Props extends RouteComponentProps<IClusterViewRouteParams> { interface Props extends RouteComponentProps<IClusterViewRouteParams> {
} }
@ -26,6 +28,9 @@ export class ClusterView extends React.Component<Props> {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.clusterId, clusterId => clusterStore.setActive(clusterId), { reaction(() => this.clusterId, clusterId => clusterStore.setActive(clusterId), {
fireImmediately: true, fireImmediately: true,
}),
reaction(() => this.cluster.online, (online) => {
if (!online) navigate(catalogURL());
}) })
]); ]);
} }

View File

@ -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...
}

View File

@ -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<Props> {
@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 (
<div className={cssNames("ClustersMenu flex column", className)}>
<div className="clusters flex column gaps">
<DragDropContext onDragEnd={this.swapClusterIconOrder}>
<Droppable droppableId="cluster-menu" type="CLUSTER">
{({ innerRef, droppableProps, placeholder }: DroppableProvided) => (
<div ref={innerRef} {...droppableProps}>
{clusters.map((cluster, index) => {
const isActive = cluster.id === activeClusterId;
return (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => (
<div ref={innerRef} {...draggableProps} {...dragHandleProps}>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={isActive}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
);
})}
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
<div className="WorkspaceMenu">
<Icon big material="menu" id="workspace-menu-icon" data-test-id="workspace-menu" />
<Menu
usePortal
htmlFor="workspace-menu-icon"
className="WorkspaceMenu"
isOpen={this.workspaceMenuVisible}
open={() => this.workspaceMenuVisible = true}
close={() => this.workspaceMenuVisible = false}
toggleEvent="click"
>
<MenuItem onClick={() => navigate(addClusterURL())} data-test-id="add-cluster-menu-item">
<Icon small material="add" /> Add Cluster
</MenuItem>
<MenuItem onClick={() => navigate(landingURL())} data-test-id="workspace-overview-menu-item">
<Icon small material="dashboard" /> Workspace Overview
</MenuItem>
</Menu>
</div>
<div className="extensions">
{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 (
<Icon
key={pageUrl}
tooltip={title}
active={isActive}
onClick={() => navigate(pageUrl)}
/>
);
})}
</div>
</div>
);
}
}
@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 (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to cluster" />
);
}
}
commandRegistry.add({
id: "workspace.chooseCluster",
title: "Workspace: Switch to cluster ...",
scope: "global",
action: () => CommandOverlay.open(<ChooseCluster />)
});

View File

@ -8,8 +8,6 @@ import { EventEmitter } from "../../../common/event-emitter";
import { subscribeToBroadcast } from "../../../common/ipc"; import { subscribeToBroadcast } from "../../../common/ipc";
import { CommandDialog } from "./command-dialog"; import { CommandDialog } from "./command-dialog";
import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry"; import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
export type CommandDialogEvent = { export type CommandDialogEvent = {
component: React.ReactElement component: React.ReactElement
@ -49,8 +47,7 @@ export class CommandContainer extends React.Component<{ clusterId?: string }> {
private runCommand(command: CommandRegistration) { private runCommand(command: CommandRegistration) {
command.action({ command.action({
cluster: clusterStore.active, entity: commandRegistry.activeEntity
workspace: workspaceStore.currentWorkspace
}); });
} }

View File

@ -5,7 +5,6 @@ import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { commandRegistry } from "../../../extensions/registries/command-registry"; import { commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { CommandOverlay } from "./command-container"; import { CommandOverlay } from "./command-container";
import { broadcastMessage } from "../../../common/ipc"; import { broadcastMessage } from "../../../common/ipc";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
@ -17,12 +16,11 @@ export class CommandDialog extends React.Component {
@computed get options() { @computed get options() {
const context = { const context = {
cluster: clusterStore.active, entity: commandRegistry.activeEntity
workspace: workspaceStore.currentWorkspace
}; };
return commandRegistry.getItems().filter((command) => { return commandRegistry.getItems().filter((command) => {
if (command.scope === "cluster" && !clusterStore.active) { if (command.scope === "entity" && !clusterStore.active) {
return false; return false;
} }
@ -56,16 +54,15 @@ export class CommandDialog extends React.Component {
if (command.scope === "global") { if (command.scope === "global") {
action({ action({
cluster: clusterStore.active, entity: commandRegistry.activeEntity
workspace: workspaceStore.currentWorkspace
}); });
} else if(clusterStore.active) { } else if(commandRegistry.activeEntity) {
navigate(clusterViewURL({ navigate(clusterViewURL({
params: { params: {
clusterId: clusterStore.active.id clusterId: commandRegistry.activeEntity.metadata.uid
} }
})); }));
broadcastMessage(`command-palette:run-action:${clusterStore.active.id}`, command.id); broadcastMessage(`command-palette:run-action:${commandRegistry.activeEntity.metadata.uid}`, command.id);
} }
} catch(error) { } catch(error) {
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error); console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);

View File

@ -136,7 +136,7 @@ export class Dock extends React.Component<Props> {
commandRegistry.add({ commandRegistry.add({
id: "cluster.openTerminal", id: "cluster.openTerminal",
title: "Cluster: Open terminal", title: "Cluster: Open terminal",
scope: "cluster", scope: "entity",
action: () => createTerminalTab(), action: () => createTerminalTab(),
isActive: (context) => !!context.cluster isActive: (context) => !!context.entity
}); });

View File

@ -39,7 +39,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
if (error) { if (error) {
const slackLink = <a href={slackUrl} rel="noreferrer" target="_blank">Slack</a>; const slackLink = <a href={slackUrl} rel="noreferrer" target="_blank">Slack</a>;
const githubLink = <a href={issuesTrackerUrl} rel="noreferrer" target="_blank">Github</a>; const githubLink = <a href={issuesTrackerUrl} rel="noreferrer" target="_blank">Github</a>;
const pageUrl = location.href; const pageUrl = location.pathname;
return ( return (
<div className="ErrorBoundary flex column gaps"> <div className="ErrorBoundary flex column gaps">

View File

@ -0,0 +1,58 @@
.HotbarMenu {
.HotbarIcon {
--size: 37px;
position: relative;
border-radius: 8px;
padding: 2px;
user-select: none;
cursor: pointer;
div.MuiAvatar-colorDefault {
font-weight:500;
text-transform: uppercase;
border-radius: 4px;
}
div.active {
background-color: var(--primary);
}
div.default {
background-color: var(--halfGray);
}
&.active {
margin-left: -3px;
border: 3px solid #fff;
}
&.active, &.interactive:hover {
div {
background-color: var(--primary);
}
img {
opacity: 1;
}
}
img {
width: var(--size);
height: var(--size);
}
}
.HotbarIconMenu {
left: 30px;
min-width: 250px;
ul {
li {
font-size: 12px;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More