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

wip: catalog

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-03-30 18:50:23 +03:00
parent 4856fdda2f
commit 8a0cf91ab4
63 changed files with 823 additions and 2091 deletions

View File

@ -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 = `
@ -76,8 +75,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
}) })
); );
}); });
@ -91,12 +89,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")).toBeUndefined(); expect(clusterStore.getById("foo")).toBeUndefined();
@ -105,7 +97,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");
}); });
}); });
@ -118,8 +109,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",
@ -127,8 +117,7 @@ describe("empty config", () => {
preferences: { preferences: {
clusterName: "dev" clusterName: "dev"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig), kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig)
workspace: "workstation"
}) })
); );
}); });
@ -138,51 +127,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);
});
}); });
}); });
@ -485,12 +434,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,188 +0,0 @@
import mockFs from "mock-fs";
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
},
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,42 @@
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);
}
getEntityForData(data: CatalogEntityData) {
const splitApiVersion = data.apiVersion.split("/");
const group = splitApiVersion[0];
const version = splitApiVersion[1];
const category = this.categories.find((category) => {
return category.spec.group === group && category.spec.names.kind === data.kind;
});
if (!category) {
return null;
}
const specVersion = category.spec.versions.find((v) => v.name === version);
if (!specVersion) {
return null;
}
return new specVersion.entityClass(data);
}
}
export const catalogCategoryRegistry = new CatalogCategoryRegistry();

View File

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

View File

@ -0,0 +1,73 @@
import { observable } from "mobx";
import { catalogCategoryRegistry } from "../catalog-category-registry";
import { CatalogCategory, CatalogEntity, CatalogEntityActionContext, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity";
import { clusterStore } from "../cluster-store";
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;
}
get cluster() {
return clusterStore.getById(this.metadata.uid);
}
async onRun(context: CatalogEntityActionContext) {
context.navigate(`/cluster/${this.metadata.uid}`);
}
async onDetailsOpen() {
//
}
async onContextMenuOpen() {
//
}
}
export class KubernetesClusterCategory 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"
}
};
}
catalogCategoryRegistry.add(new KubernetesClusterCategory());

View File

@ -0,0 +1,32 @@
import { action, computed, observable } from "mobx";
import { CatalogEntity } from "./catalog-entity";
export class CatalogEntityRegistry {
@observable.deep protected sources: Map<string, CatalogEntity[]> = observable.map(new Map(), { deep: true });
@action addSource(id: string, source: CatalogEntity[]) {
this.sources.set(id, source);
}
@action removeSource(id: string) {
this.sources.delete(id);
}
@computed get items() {
const catalogItems: CatalogEntity[] = [];
for (const [id, items] of this.sources) {
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,63 @@
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;
}
}
}
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;
}
export interface CatalogEntityContextMenuContext extends CatalogEntityActionContext {
menu: any
}
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

@ -4,6 +4,7 @@ import { appEventBus } from "./event-bus";
import { ResourceApplier } from "../main/resource-applier"; import { ResourceApplier } from "../main/resource-applier";
import { ipcMain, IpcMainInvokeEvent } from "electron"; import { ipcMain, IpcMainInvokeEvent } from "electron";
import { clusterFrameMap } from "./cluster-frames"; import { clusterFrameMap } from "./cluster-frames";
import { catalogEntityRegistry } from "./catalog-entity-registry";
export const clusterActivateHandler = "cluster:activate"; export const clusterActivateHandler = "cluster:activate";
export const clusterSetFrameIdHandler = "cluster:set-frame-id"; export const clusterSetFrameIdHandler = "cluster:set-frame-id";
@ -17,6 +18,10 @@ if (ipcMain) {
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster").forEach((item) => {
if (item.metadata.uid === cluster.id) item.status.active = true;
});
return cluster.activate(force); return cluster.activate(force);
} }
}); });
@ -43,6 +48,9 @@ if (ipcMain) {
if (cluster) { if (cluster) {
cluster.disconnect(); cluster.disconnect();
catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster").forEach((item) => {
if (item.metadata.uid === cluster.id) item.status.active = false;
});
clusterFrameMap.delete(cluster.id); clusterFrameMap.delete(cluster.id);
} }
}); });

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,7 @@ 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 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,9 +44,6 @@ export interface ClusterModel {
/** Path to cluster kubeconfig */ /** Path to cluster kubeconfig */
kubeConfigPath: string; kubeConfigPath: string;
/** Workspace id */
workspace?: WorkspaceId;
/** User context in kubeconfig */ /** User context in kubeconfig */
contextName?: string; contextName?: string;
@ -110,6 +104,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@observable removedClusters = observable.map<ClusterId, Cluster>(); @observable removedClusters = observable.map<ClusterId, Cluster>();
@observable clusters = observable.map<ClusterId, Cluster>(); @observable clusters = observable.map<ClusterId, Cluster>();
private static stateRequestChannel = "cluster:states"; private static stateRequestChannel = "cluster:states";
private constructor() { private constructor() {
@ -222,7 +217,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
const clusterId = this.clusters.has(id) ? id : null; const clusterId = this.clusters.has(id) ? id : null;
this.activeCluster = clusterId; this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
} }
deactivate(id: ClusterId) { deactivate(id: ClusterId) {
@ -232,8 +226,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
@action @action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) { swapIconOrders(from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace); const clusters = this.enabledClustersList;
if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) { if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) {
throw new Error(`invalid from<->to arguments`); throw new Error(`invalid from<->to arguments`);
@ -255,13 +249,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.get(id); return this.clusters.get(id);
} }
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[] = [];
@ -313,13 +300,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

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

View File

@ -1,8 +1 @@
export { ExtensionStore } from "../extension-store"; export { ExtensionStore } from "../extension-store";
export { clusterStore, Cluster, ClusterStore } from "../stores/cluster-store";
export type { ClusterModel, ClusterId } from "../stores/cluster-store";
export { workspaceStore, Workspace, WorkspaceStore } from "../stores/workspace-store";
export type { WorkspaceId, WorkspaceModel } from "../stores/workspace-store";

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);
}
removeCatalogSorce(id: string) {
catalogEntityRegistry.removeSource(`${this.name}:${id}`);
}
} }

View File

@ -1,14 +1,12 @@
// Extensions API -> Commands // Extensions API -> Commands
import type { Cluster } from "../../main/cluster"; 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 } from "mobx";
import { LensExtension } from "../lens-extension"; import { LensExtension } from "../lens-extension";
export type CommandContext = { export type CommandContext = {
cluster?: Cluster; cluster?: Cluster;
workspace?: Workspace;
}; };
export interface CommandRegistration { export interface CommandRegistration {

View File

@ -1,128 +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.activeCluster = 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 {
if (!this.activeClusterId) {
return null;
}
return this.getById(this.activeClusterId);
}
/**
* 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 this.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,26 @@
import { autorun, toJS } from "mobx";
import { broadcastMessage, subscribeToBroadcast } 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();
}
constructor(private catalog: CatalogEntityRegistry) {}
init() {
autorun(() => {
this.broadcast();
});
subscribeToBroadcast("catalog:broadcast", () => {
this.broadcast();
});
}
broadcast() {
broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true }));
}
}

View File

@ -1,16 +1,23 @@
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";
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 +26,14 @@ 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 });
// 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 +53,55 @@ 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.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 entity = this.catalogSource[entityIndex] as KubernetesCluster;
newEntity.status.active = entity.status.active;
this.catalogSource.splice(entityIndex, 1, newEntity);
}
});
}
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["distro"]?.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,12 +103,6 @@ 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
* *
@ -611,7 +604,6 @@ export class Cluster implements ClusterModel, ClusterState {
id: this.id, id: this.id,
contextName: this.contextName, contextName: this.contextName,
kubeConfigPath: this.kubeConfigPath, kubeConfigPath: this.kubeConfigPath,
workspace: this.workspace,
preferences: this.preferences, preferences: this.preferences,
metadata: this.metadata, metadata: this.metadata,
ownerRef: this.ownerRef, ownerRef: this.ownerRef,

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,8 @@ 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";
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number; let proxyPort: number;
@ -107,7 +108,6 @@ app.on("ready", async () => {
await Promise.all([ await Promise.all([
userStore.load(), userStore.load(),
clusterStore.load(), clusterStore.load(),
workspaceStore.load(),
extensionsStore.load(), extensionsStore.load(),
filesystemProvisionerStore.load(), filesystemProvisionerStore.load(),
]); ]);
@ -164,6 +164,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 { landingURL } from "../renderer/components/+landing-page/landing-page.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(landingURL());
}
},
{ {
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";
@ -74,31 +71,6 @@ function createTrayMenu(windowManager: WindowManager): Menu {
windowManager.navigate(preferencesURL()); windowManager.navigate(preferencesURL());
}, },
}, },
{
label: "Clusters",
submenu: workspaceStore.enabledWorkspacesList
.filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces
.map(workspace => {
const clusters = clusterStore.getByWorkspaceId(workspace.id);
return {
label: workspace.name,
toolTip: workspace.description,
submenu: clusters.map(cluster => {
const { id: clusterId, name: label, online, workspace } = cluster;
return {
label: `${online ? "✓" : "\x20".repeat(3)/*offset*/}${label}`,
toolTip: clusterId,
async click() {
workspaceStore.setActive(workspace);
windowManager.navigate(clusterViewURL({ params: { clusterId } }));
}
};
})
};
}),
},
{ {
label: "Check for updates", label: "Check for updates",
async click() { async click() {

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

@ -0,0 +1,56 @@
import { action, observable } from "mobx";
import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc";
import { CatalogEntity, CatalogEntityData } from "../../common/catalog-entity";
import { catalogCategoryRegistry, CatalogCategoryRegistry } from "../../common/catalog-category-registry";
import "../../common/catalog-entities";
export { CatalogEntity, CatalogEntityData } from "../../common/catalog-entity";
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[];
}
}
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);

View File

@ -10,7 +10,6 @@ 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";
@ -56,7 +55,6 @@ export async function bootstrap(App: AppComponent) {
// preload common stores // preload common stores
await Promise.all([ await Promise.all([
userStore.load(), userStore.load(),
workspaceStore.load(),
clusterStore.load(), clusterStore.load(),
extensionsStore.load(), extensionsStore.load(),
filesystemProvisionerStore.load(), filesystemProvisionerStore.load(),
@ -65,7 +63,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 +71,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,7 +12,6 @@ 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";
@ -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,

View File

@ -9,7 +9,6 @@ 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 +57,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>
</> </>
); );

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,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

@ -0,0 +1,48 @@
import { computed, reaction } from "mobx";
import { CatalogEntity, catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { ItemObject, ItemStore } from "../../item.store";
import { autobind } from "../../utils";
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;
}
get phase() {
return this.entity.status.phase;
}
onRun(ctx: any) {
this.entity.onRun(ctx);
}
}
@autobind()
export class CatalogEntityStore extends ItemStore<CatalogEntityItem> {
@computed get entities() {
return catalogEntityRegistry.items.map(entity => new CatalogEntityItem(entity));
}
watch() {
return reaction(() => this.entities, () => this.loadAll());
}
loadAll() {
return this.loadItems(() => this.entities);
}
}

View File

@ -1,12 +1,8 @@
.PageLayout.LandingOverview { .LandingPage {
--width: 100%; --width: 100%;
--height: 100%; --height: 100%;
text-align: center;
.content-wrapper { .content-wrapper {
.content { .content {
margin: unset; margin: unset;
max-width: unset; max-width: unset;
@ -14,3 +10,13 @@
} }
} }
} }
.TableCell.status {
&.connected {
color: var(--colorSuccess);
}
&.disconnected {
color: var(--halfGray);
}
}

View File

@ -1,42 +1,60 @@
import "./landing-page.scss"; import "./landing-page.scss";
import React from "react"; import React from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { clusterStore } from "../../../common/cluster-store"; import { ItemListLayout } from "../item-object-list";
import { workspaceStore } from "../../../common/workspace-store"; import { IReactionDisposer, observable } from "mobx";
import { WorkspaceOverview } from "./workspace-overview"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store";
import { navigate } from "../../navigation";
import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { Notifications } from "../notifications";
import { Icon } from "../icon"; enum sortBy {
name = "name",
status = "status"
}
@observer @observer
export class LandingPage extends React.Component { export class LandingPage extends React.Component {
@observable showHint = true; @observable private catalogEntityStore?: CatalogEntityStore;
private disposers: IReactionDisposer[] = [];
@computed
get clusters() {
return clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
}
componentDidMount() { componentDidMount() {
const noClustersInScope = !this.clusters.length; this.catalogEntityStore = new CatalogEntityStore();
const showStartupHint = this.showHint; this.disposers.push(this.catalogEntityStore.watch());
}
if (showStartupHint && noClustersInScope) { componentWillUnmount() {
Notifications.info(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, { this.disposers.forEach((d) => d());
timeout: 30_000,
id: "landing-welcome"
});
}
} }
render() { render() {
const showBackButton = this.clusters.length > 0; if (!this.catalogEntityStore) {
const header = <><Icon svg="logo-lens" big /> <h2>{workspaceStore.currentWorkspace.name}</h2></>; return null;
}
return ( return (
<PageLayout className="LandingOverview flex" header={header} provideBackButtonNavigation={showBackButton} showOnTop={true}> <PageLayout className="LandingPage">
<WorkspaceOverview /> <ItemListLayout
renderHeaderTitle="Catalog"
isClusterScoped
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem) => item.name,
[sortBy.status]: (item: CatalogEntityItem) => item.phase,
}}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Status", className: "status", sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
item.name,
{ title: item.phase, className: kebabCase(item.phase) }
]}
onDetails={(item: CatalogEntityItem) => item.onRun({ navigate: (url: string) => navigate(url)})}
/>
</PageLayout> </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,72 +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";
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;
}
loadAll() {
return this.loadItems(
() => (
clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster))
)
);
}
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,85 +0,0 @@
import "./workspace-overview.scss";
import React, { Component } from "react";
import { disposeOnUnmount, 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 { 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;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => workspaceStore.currentWorkspaceId, workspaceId => {
this.workspaceClusterStore = new WorkspaceClusterStore(workspaceId);
this.workspaceClusterStore.loadAll().catch(error => console.log("workspaceClusterStore.loadAll", error));
}, {
fireImmediately: true,
})
]);
}
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

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

@ -2,11 +2,7 @@ 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 { ChooseWorkspace } from "../+workspaces";
@observer @observer
export class BottomBar extends React.Component { export class BottomBar extends React.Component {
@ -45,14 +41,8 @@ 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 />)}>
<Icon smallest material="layers"/>
<span className="workspace-name" data-test-id="current-workspace-name">{currentWorkspace.name}</span>
</div>
{this.renderRegisteredItems()} {this.renderRegisteredItems()}
</div> </div>
); );

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 { LandingPage, landingRoute, landingURL } from "../+landing-page";
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,16 +44,6 @@ export class ClusterManager extends React.Component {
} }
get startUrl() { get startUrl() {
const { activeClusterId } = clusterStore;
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
});
}
return landingURL(); return landingURL();
} }
@ -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

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

@ -9,7 +9,6 @@ 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 { 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 +48,7 @@ export class CommandContainer extends React.Component<{ clusterId?: string }> {
private runCommand(command: CommandRegistration) { private runCommand(command: CommandRegistration) {
command.action({ command.action({
cluster: clusterStore.active, cluster: clusterStore.active
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,8 +16,7 @@ export class CommandDialog extends React.Component {
@computed get options() { @computed get options() {
const context = { const context = {
cluster: clusterStore.active, cluster: clusterStore.active
workspace: workspaceStore.currentWorkspace
}; };
return commandRegistry.getItems().filter((command) => { return commandRegistry.getItems().filter((command) => {
@ -56,8 +54,7 @@ export class CommandDialog extends React.Component {
if (command.scope === "global") { if (command.scope === "global") {
action({ action({
cluster: clusterStore.active, cluster: clusterStore.active
workspace: workspaceStore.currentWorkspace
}); });
} else if(clusterStore.active) { } else if(clusterStore.active) {
navigate(clusterViewURL({ navigate(clusterViewURL({

View File

@ -0,0 +1,44 @@
.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);
}
}

View File

@ -0,0 +1,58 @@
import "./hotbar-icon.scss";
import React, { DOMAttributes } from "react";
import { observer } from "mobx-react";
import { cssNames, IClassName } from "../../utils";
import { Tooltip } from "../tooltip";
import { Avatar } from "@material-ui/core";
import { CatalogEntity } from "../../../common/catalog-entity";
interface Props extends DOMAttributes<HTMLElement> {
entity: CatalogEntity;
className?: IClassName;
errorClass?: IClassName;
isActive?: boolean;
}
@observer
export class HotbarIcon extends React.Component<Props> {
get iconString() {
let splittedName = this.props.entity.metadata.name.split(" ");
if (splittedName.length === 1) {
splittedName = splittedName[0].split("-");
}
if (splittedName.length === 1) {
splittedName = splittedName[0].split("@");
}
splittedName = splittedName.map((part) => part.replace(/\W/g, ""));
if (splittedName.length === 1) {
return splittedName[0].substring(0, 2);
} else {
return splittedName[0].substring(0, 1) + splittedName[1].substring(0, 1);
}
}
render() {
const {
entity, errorClass, isActive,
children, ...elemProps
} = this.props;
const entityIconId = `hotbar-icon-${entity.metadata.uid}`;
const className = cssNames("HotbarIcon flex inline", this.props.className, {
interactive: true,
active: isActive,
});
return (
<div {...elemProps} className={className} id={entityIconId}>
<Tooltip targetId={entityIconId}>{entity.metadata.name}</Tooltip>
<Avatar variant="square" className={isActive ? "active" : "default"}>{this.iconString}</Avatar>
{children}
</div>
);
}
}

View File

@ -0,0 +1,29 @@
.HotbarMenu {
$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;
}
}
}

View File

@ -0,0 +1,43 @@
import "./hotbar-menu.scss";
import React from "react";
import { observer } from "mobx-react";
import { HotbarIcon } from "./hotbar-icon";
import { cssNames, IClassName } from "../../utils";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { KubernetesCluster } from "../../../common/catalog-entities/kubernetes-cluster";
import { navigate } from "../../navigation";
interface Props {
className?: IClassName;
}
@observer
export class HotbarMenu extends React.Component<Props> {
render() {
const { className } = this.props;
const items = catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster");
const runContext = {
navigate: (url: string) => navigate(url)
};
return (
<div className={cssNames("HotbarMenu flex column", className)}>
<div className="clusters flex column gaps">
{items.map((entity) => {
return (
<HotbarIcon
key={entity.metadata.uid}
entity={entity}
isActive={entity.status.active}
onClick={() => entity.onRun(runContext)}
onContextMenu={() => entity.onContextMenuOpen()}
/>
);
})}
</div>
</div>
);
}
}

View File

@ -6,7 +6,6 @@ import "@testing-library/jest-dom/extend-expect";
import { MainLayoutHeader } from "../main-layout-header"; import { MainLayoutHeader } from "../main-layout-header";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { workspaceStore } from "../../../../common/workspace-store";
import { broadcastMessage, requestMain } from "../../../../common/ipc"; import { broadcastMessage, requestMain } from "../../../../common/ipc";
import { clusterDisconnectHandler } from "../../../../common/cluster-ipc"; import { clusterDisconnectHandler } from "../../../../common/cluster-ipc";
import { ConfirmDialog } from "../../confirm-dialog"; import { ConfirmDialog } from "../../confirm-dialog";
@ -19,7 +18,6 @@ const cluster: Cluster = new Cluster({
id: "foo", id: "foo",
contextName: "minikube", contextName: "minikube",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId,
}); });
describe("<MainLayoutHeader />", () => { describe("<MainLayoutHeader />", () => {

View File

@ -9,7 +9,7 @@ import { NavigationTree, RecursiveTreeView } from "../tree-view";
export interface PageLayoutProps extends React.DOMAttributes<any> { export interface PageLayoutProps extends React.DOMAttributes<any> {
className?: IClassName; className?: IClassName;
header: React.ReactNode; header?: React.ReactNode;
headerClass?: IClassName; headerClass?: IClassName;
contentClass?: IClassName; contentClass?: IClassName;
provideBackButtonNavigation?: boolean; provideBackButtonNavigation?: boolean;
@ -65,7 +65,7 @@ export class PageLayout extends React.Component<PageLayoutProps> {
return ( return (
<div {...elemProps} className={className}> <div {...elemProps} className={className}>
<div className={cssNames("header flex gaps align-center", headerClass)}> { header && (<div className={cssNames("header flex gaps align-center", headerClass)}>
{header} {header}
{provideBackButtonNavigation && ( {provideBackButtonNavigation && (
<Icon <Icon
@ -75,6 +75,10 @@ export class PageLayout extends React.Component<PageLayoutProps> {
/> />
)} )}
</div> </div>
)}
{ !header && (
<div className="flex gaps"></div>
)}
{ navigation && ( { navigation && (
<nav className="content-navigation"> <nav className="content-navigation">
<RecursiveTreeView data={navigation}/> <RecursiveTreeView data={navigation}/>

View File

@ -16,10 +16,12 @@ import { LensProtocolRouterRenderer, bindProtocolAddRouteHandlers } from "./prot
import { registerIpcHandlers } from "./ipc"; import { registerIpcHandlers } from "./ipc";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { IpcRendererNavigationEvents } from "./navigation/events"; import { IpcRendererNavigationEvents } from "./navigation/events";
import { catalogEntityRegistry } from "./api/catalog-entity-registry";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
static async init() { static async init() {
catalogEntityRegistry.init();
extensionLoader.loadOnClusterManagerRenderer(); extensionLoader.loadOnClusterManagerRenderer();
LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>().init(); LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>().init();
bindProtocolAddRouteHandlers(); bindProtocolAddRouteHandlers();

View File

@ -7,7 +7,6 @@ import { clusterViewURL } from "../components/cluster-manager/cluster-view.route
import { LensProtocolRouterRenderer } from "./router"; import { LensProtocolRouterRenderer } from "./router";
import { navigate } from "../navigation/helpers"; import { navigate } from "../navigation/helpers";
import { clusterStore } from "../../common/cluster-store"; import { clusterStore } from "../../common/cluster-store";
import { workspaceStore } from "../../common/workspace-store";
export function bindProtocolAddRouteHandlers() { export function bindProtocolAddRouteHandlers() {
LensProtocolRouterRenderer LensProtocolRouterRenderer
@ -21,14 +20,6 @@ export function bindProtocolAddRouteHandlers() {
.addInternalHandler("/landing", () => { .addInternalHandler("/landing", () => {
navigate(landingURL()); navigate(landingURL());
}) })
.addInternalHandler("/landing/:workspaceId", ({ pathname: { workspaceId } }) => {
if (workspaceStore.getById(workspaceId)) {
workspaceStore.setActive(workspaceId);
navigate(landingURL());
} else {
console.log("[APP-HANDLER]: workspace with given ID does not exist", { workspaceId });
}
})
.addInternalHandler("/cluster", () => { .addInternalHandler("/cluster", () => {
navigate(addClusterURL()); navigate(addClusterURL());
}) })
@ -36,7 +27,6 @@ export function bindProtocolAddRouteHandlers() {
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
workspaceStore.setActive(cluster.workspace);
navigate(clusterViewURL({ params: { clusterId } })); navigate(clusterViewURL({ params: { clusterId } }));
} else { } else {
console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId }); console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId });
@ -46,7 +36,6 @@ export function bindProtocolAddRouteHandlers() {
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
workspaceStore.setActive(cluster.workspace);
navigate(clusterSettingsURL({ params: { clusterId } })); navigate(clusterSettingsURL({ params: { clusterId } }));
} else { } else {
console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId }); console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId });