From 111356521ac77ebaf43ce1492e30612e847787cf Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 4 May 2021 11:12:23 -0400 Subject: [PATCH] Rework ClusterCatalogRegistry to be a source of truth Signed-off-by: Sebastian Malton move to not storing the Categories anywhere else besides the registry Signed-off-by: Sebastian Malton use KubernetesCluster Signed-off-by: Sebastian Malton more work towards ClusterManager being the sole owner of ClusterInstances Signed-off-by: Sebastian Malton making ClusterManger the source of KubernetesClusters status Signed-off-by: Sebastian Malton --- .../metrics-cluster-feature/renderer.tsx | 66 ++++- src/common/base-store.ts | 9 +- .../catalog-entities/kubernetes-cluster.ts | 116 +++----- src/common/catalog-entities/web-link.ts | 41 +-- .../catalog/catalog-category-registry.ts | 264 +++++++++++++++--- src/common/catalog/catalog-entity-registry.ts | 29 +- src/common/catalog/catalog-entity.ts | 146 ++++++---- src/common/cluster-frames.ts | 29 -- src/common/cluster-ipc.ts | 11 +- src/common/cluster-store.ts | 10 - src/common/default-categories.ts | 52 ++++ .../icons/kubernetes.svg | 0 src/common/ipc/ipc.ts | 11 +- src/common/utils/extended-map.ts | 33 ++- src/extensions/core-api/catalog.ts | 60 +++- src/extensions/core-api/utils.ts | 2 +- src/extensions/lens-extension.ts | 37 +-- src/extensions/lens-main-extension.ts | 6 +- src/main/catalog-pusher.ts | 19 +- .../__test__/kubeconfig-sync.test.ts | 10 +- src/main/catalog-sources/kubeconfig-sync.ts | 82 +++--- src/main/cluster-manager.ts | 223 ++++++++------- src/main/cluster.ts | 21 +- src/main/index.ts | 10 +- src/main/resource-applier.ts | 6 +- src/main/window-manager.ts | 43 ++- .../__tests__/catalog-entity-registry.test.ts | 11 +- src/renderer/api/catalog-category-registry.ts | 2 +- src/renderer/api/catalog-entity-registry.ts | 32 ++- src/renderer/api/catalog-entity.ts | 16 +- src/renderer/bootstrap.tsx | 20 +- .../+catalog/catalog-add-button.tsx | 20 +- .../+catalog/catalog-entity.store.ts | 21 +- src/renderer/components/+catalog/catalog.tsx | 60 ++-- .../+entity-settings/entity-settings.tsx | 4 +- src/renderer/components/app.tsx | 84 +++--- src/renderer/components/badge/badge.tsx | 8 +- src/renderer/components/button/button.tsx | 2 + .../cluster-manager/cluster-status.tsx | 13 +- .../cluster-manager/cluster-view.tsx | 15 +- .../components/hotbar/hotbar-icon.tsx | 7 +- .../components/hotbar/hotbar-menu.tsx | 10 +- src/renderer/components/menu/menu-actions.tsx | 11 +- src/renderer/lens-app.tsx | 6 +- src/renderer/protocol-handler/app-handlers.ts | 4 +- src/renderer/utils/createStorage.ts | 3 + webpack.extensions.ts | 3 + yarn.lock | 24 +- 48 files changed, 1027 insertions(+), 685 deletions(-) delete mode 100644 src/common/cluster-frames.ts create mode 100644 src/common/default-categories.ts rename src/common/{catalog-entities => }/icons/kubernetes.svg (100%) diff --git a/extensions/metrics-cluster-feature/renderer.tsx b/extensions/metrics-cluster-feature/renderer.tsx index f0d227f0a7..482978cfc5 100644 --- a/extensions/metrics-cluster-feature/renderer.tsx +++ b/extensions/metrics-cluster-feature/renderer.tsx @@ -18,11 +18,12 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { Catalog, Interface, LensRendererExtension } from "@k8slens/extensions"; import React from "react"; -import { LensRendererExtension, Catalog } from "@k8slens/extensions"; import { MetricsSettings } from "./src/metrics-settings"; + export default class ClusterMetricsFeatureExtension extends LensRendererExtension { entitySettings = [ { @@ -39,4 +40,67 @@ export default class ClusterMetricsFeatureExtension extends LensRendererExtensio } } ]; + onActivate() { + this.disposers.push( + Catalog.CatalogCategoryRegistry.registerHandler( + "entity.k8slens.dev/v1alpha1", + "KubernetesCluster", + "onContextMenuOpen", + this.clusterContextMenuOpen, + ), + ); + } + + clusterContextMenuOpen = (cluster: Catalog.KubernetesCluster): Interface.ContextMenu[] => { + if (!cluster.status.active) { + return []; + } + + const metricsFeature = new MetricsFeature(); + + return [ + { + icon: "refresh", + title: "Upgrade Lens Metrics stack", + onClick: async () => { + metricsFeature.upgrade(cluster); + } + }, + ]; + + // const metricsFeature = new MetricsFeature(); + + // await metricsFeature.updateStatus(cluster); + + // if (metricsFeature.status.installed) { + // if (metricsFeature.status.canUpgrade) { + // ctx.menuItems.unshift({ + // icon: "refresh", + // title: "Upgrade Lens Metrics stack", + // onClick: async () => { + // metricsFeature.upgrade(cluster); + // } + // }); + // } + // ctx.menuItems.unshift({ + // icon: "toggle_off", + // title: "Uninstall Lens Metrics stack", + // onClick: async () => { + // await metricsFeature.uninstall(cluster); + + // Component.Notifications.info(`Lens Metrics has been removed from ${cluster.metadata.name}`, { timeout: 10_000 }); + // } + // }); + // } else { + // ctx.menuItems.unshift({ + // icon: "toggle_on", + // title: "Install Lens Metrics stack", + // onClick: async () => { + // metricsFeature.install(cluster); + + // Component.Notifications.info(`Lens Metrics is now installed to ${cluster.metadata.name}`, { timeout: 10_000 }); + // } + // }); + // } + }; } diff --git a/src/common/base-store.ts b/src/common/base-store.ts index cb2b285b4d..66687e302b 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -18,16 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import path from "path"; import Config from "conf"; import type { Options as ConfOptions } from "conf/dist/source/types"; import { app, ipcMain, ipcRenderer, remote } from "electron"; +import isEqual from "lodash/isEqual"; import { IReactionOptions, observable, reaction, runInAction, when } from "mobx"; -import { Singleton, getAppVersion } from "./utils"; +import path from "path"; + import logger from "../main/logger"; import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc"; -import isEqual from "lodash/isEqual"; +import { getAppVersion } from "./utils/app-version"; +import Singleton from "./utils/singleton"; export interface BaseStoreParams extends ConfOptions { autoLoad?: boolean; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 0e9a4bed67..63710c09cf 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -18,16 +18,15 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; -import { CatalogEntity, CatalogEntityActionContext, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus, CatalogCategory, CatalogCategorySpec } from "../catalog"; -import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; +import { app } from "electron"; +import { CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import type { ActionContext, ContextMenu, MenuContext } from "../catalog/catalog-entity"; +import * as clusterIpc from "../cluster-ipc"; import { ClusterStore } from "../cluster-store"; import { requestMain } from "../ipc"; -import { productName } from "../vars"; -import { addClusterURL } from "../routes"; import { storedKubeConfigFolder } from "../utils"; -import { app } from "electron"; +import { productName } from "../vars"; + export type KubernetesClusterPrometheusMetrics = { address?: { @@ -49,7 +48,7 @@ export type KubernetesClusterSpec = { }; export interface KubernetesClusterStatus extends CatalogEntityStatus { - phase: "connected" | "disconnected"; + phase?: "connected" | "disconnected"; } export class KubernetesCluster extends CatalogEntity { @@ -67,7 +66,7 @@ export class KubernetesCluster extends CatalogEntity { context.navigate(`/cluster/${this.metadata.uid}`); - } + }; - onDetailsOpen(): void { - // - } + onContextMenuOpen = (context: MenuContext) => { + const res: ContextMenu[] = []; - onSettingsOpen(): void { - // - } + if (this.status.phase == "connected") { + res.push({ + icon: "link_off", + title: "Disconnect", + onClick: async () => { + ClusterStore.getInstance().deactivate(this.metadata.uid); + requestMain(clusterIpc.disconnect, this.metadata.uid); + } + }); + } - async onContextMenuOpen(context: CatalogEntityContextMenuContext) { - context.menuItems = [ - { - title: "Settings", - onlyVisibleForSource: "local", - onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`) - }, - ]; + res.push({ + icon: "settings", + title: "Settings", + onlyVisibleForSource: "local", + onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`) + }); if (this.metadata.labels["file"]?.startsWith(storedKubeConfigFolder())) { - context.menuItems.push({ + res.push({ + icon: "delete", title: "Delete", onlyVisibleForSource: "local", onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), @@ -120,62 +124,6 @@ export class KubernetesCluster extends CatalogEntity { - ClusterStore.getInstance().deactivate(this.metadata.uid); - requestMain(clusterDisconnectHandler, this.metadata.uid); - } - }); - } else { - context.menuItems.push({ - title: "Connect", - onClick: async () => { - context.navigate(`/cluster/${this.metadata.uid}`); - } - }); - } - - const category = catalogCategoryRegistry.getCategoryForEntity(this); - - if (category) category.emit("contextMenuOpen", this, context); - } -} - -export class KubernetesClusterCategory extends CatalogCategory { - public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; - public readonly kind = "CatalogCategory"; - public metadata = { - name: "Kubernetes Clusters", - icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line + return res; }; - public spec: CatalogCategorySpec = { - group: "entity.k8slens.dev", - versions: [ - { - name: "v1alpha1", - entityClass: KubernetesCluster - } - ], - names: { - kind: "KubernetesCluster" - } - }; - - constructor() { - super(); - - this.on("onCatalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { - ctx.menuItems.push({ - icon: "text_snippet", - title: "Add from kubeconfig", - onClick: () => { - ctx.navigate(addClusterURL()); - } - }); - }); - } } - -catalogCategoryRegistry.add(new KubernetesClusterCategory()); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 7e0f024421..6d66b48c43 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -18,9 +18,7 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; +import { CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; export interface WebLinkStatus extends CatalogEntityStatus { phase: "valid" | "invalid"; @@ -34,42 +32,7 @@ export class WebLink extends CatalogEntity { window.open(this.spec.url, "_blank"); - } - - public onSettingsOpen(): void { - return; - } - - public onDetailsOpen(): void { - return; - } - - public onContextMenuOpen(): void { - return; - } -} - -export class WebLinkCategory extends CatalogCategory { - public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; - public readonly kind = "CatalogCategory"; - public metadata = { - name: "Web Links", - icon: "link" - }; - public spec = { - group: "entity.k8slens.dev", - versions: [ - { - name: "v1alpha1", - entityClass: WebLink - } - ], - names: { - kind: "WebLink" - } }; } - -catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/catalog/catalog-category-registry.ts b/src/common/catalog/catalog-category-registry.ts index 886db3e90a..7187527683 100644 --- a/src/common/catalog/catalog-category-registry.ts +++ b/src/common/catalog/catalog-category-registry.ts @@ -18,60 +18,234 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { action, computed, observable, when } from "mobx"; -import { action, computed, observable, toJS } from "mobx"; -import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import { navigate } from "../../renderer/navigation"; +import { Rest } from "../ipc"; +import { Disposer, disposer, ExtendedMap, Singleton, StrictMap } from "../utils"; +import { + AddMenuOpenHandler, + CatalogCategorySpec, + CatalogCategoryVersion, + CatalogEntity, + CatalogEntityConstructor, + CatalogEntityData, + CatalogEntityKindData, + CategoryHandler, + ContextMenuOpenHandler, + MatchingCatalogEntityData, + parseApiVersion, + SettingsMenuOpenHandler, +} from "./catalog-entity"; -export class CatalogCategoryRegistry { - @observable protected categories: CatalogCategory[] = []; - @action add(category: CatalogCategory) { - this.categories.push(category); - } +type KeysMatching = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T]; +type KeysNotMatching = { [K in keyof T]-?: T[K] extends V ? never : K }[keyof T]; - @action remove(category: CatalogCategory) { - this.categories = this.categories.filter((cat) => cat.apiVersion !== category.apiVersion && cat.kind !== category.kind); - } +export type CategoryHandlers = { + [HandlerName in KeysMatching>]?: CatalogCategory[HandlerName] extends Set ? Handler : never; +}; +export type CategoryHandlerNames = keyof CategoryHandlers; +export type CatalogHandler = CategoryHandlers[Name]; - @computed get items() { - return toJS(this.categories); - } +export type EntityContextHandlers = keyof EntityContextGetters; +export type GlobalContextHandlers = keyof GlobalContextGetters; - getForGroupKind(group: string, kind: string) { - return this.categories.find((c) => c.spec.group === group && c.spec.names.kind === kind) as T; - } +type EntityContextGetters = { + [HandlerName in KeysMatching any>>]: () => Rest>; +}; - getEntityForData(data: CatalogEntityData & CatalogEntityKindData) { - const category = this.getCategoryForEntity(data); +type GlobalContextGetters = { + [HandlerName in KeysNotMatching any>>]: () => Parameters; +}; - if (!category) { - return null; +const EntityContexts: EntityContextGetters = { + onContextMenuOpen: () => [{ navigate }], + onSettingsOpen: () => [{ navigate }], +}; +const GlobalContexts: GlobalContextGetters = { + onAddMenuOpen: () => [{ navigate }], +}; + +/** + * Note: this type shouldn't be exported or leaked out of this file. + * The registry should do everything for any consumer of this type. + */ +class CatalogCategory implements CatalogCategorySpec { + onContextMenuOpen = new Set>(); + onSettingsOpen = new Set>(); + onAddMenuOpen = new Set(); + + public readonly id: string; + + public readonly apiVersion: string; + public readonly kind: string; + public readonly metadata: { + name: string; + icon: string; + }; + + public readonly spec: { + group: string; + versions: CatalogCategoryVersion[]; + names: { + kind: string; + }; + }; + + constructor(specAndHandlers: CatalogCategorySpec & CategoryHandlers) { + const { apiVersion, kind, metadata, spec, ...handlers } = specAndHandlers; + + this.spec = spec; + this.apiVersion = apiVersion; + this.kind = kind; + this.metadata = metadata; + this.id = `${spec.group}/${spec.names.kind}`; + + for (const name of Object.keys(handlers)) { + const handlerName = name as CategoryHandlerNames; + + if (typeof handlers[handlerName] === "function") { + this[handlerName].add(handlers[handlerName] as any); + } } - - const splitApiVersion = data.apiVersion.split("/"); - const version = splitApiVersion[1]; - - const specVersion = category.spec.versions.find((v) => v.name === version); - - if (!specVersion) { - return null; - } - - return new specVersion.entityClass(data); - } - - getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData) { - const splitApiVersion = data.apiVersion.split("/"); - const group = splitApiVersion[0]; - - const category = this.categories.find((category) => { - return category.spec.group === group && category.spec.names.kind === data.kind; - }); - - if (!category) return null; - - return category as T; } } -export const catalogCategoryRegistry = new CatalogCategoryRegistry(); +export class CatalogCategoryRegistry extends Singleton { + /** + * The three levels of keys are: (for category ApiVersions) + * 1. `GROUP` + * 2. `VERSION` + */ + protected categories = observable.set(); + + /** + * The three levels of keys are: (by entity ApiVersions) + * 1. `GROUP` + * 2. `VERSION` + * 3. `KIND` + */ + @computed protected get entityToCategoryTable(): Map]>>> { + const res = ExtendedMap.newExtendedStrict]>(); + + for (const category of this.categories.values()) { + const grouping = res.getOrDefault(category.spec.group); + + for (const { version, entityClass } of category.spec.versions) { + grouping + .getOrDefault(version) + .strictSet(category.spec.names.kind, [category, entityClass]); + } + } + + return res; + } + + @computed protected get categoryIdLookup(): Map { + const res = new StrictMap(); + + for (const category of this.categories.values()) { + res.strictSet(category.id, category); + } + + return res; + } + + /** + * Registers (and potentially overrides a previous category) + * @param specAndHandlers The Category spec and initial handlers to register + * @returns the ability to remove this category + */ + @action add(specAndHandlers: CatalogCategorySpec & CategoryHandlers): Disposer { + parseApiVersion(specAndHandlers.apiVersion); // make sure this is valid + const category = new CatalogCategory(specAndHandlers); + + this.categories.add(category); + + return () => void this.categories.delete(category); + } + + @computed get items(): CatalogCategorySpec[] { + return Array.from(this.categoryIdLookup.values()); + } + + getById(id: string): CatalogCategorySpec | undefined { + return this.categoryIdLookup.get(id); + } + + /** + * Gets the `CatalogCategory` once it has been registered + * @param apiVersion the ApiVersion string of the category + * @param kind the kind of entity that is desired + */ + registerHandler(apiVersion: string, kind: string, handlerName: CategoryHandlerNames, handler: CatalogHandler): Disposer { + const { group, version } = parseApiVersion(apiVersion, false); + + if (version) { + // only one version to do + return disposer( + when( + () => this.entityToCategoryTable.get(group)?.get(version)?.has(kind), + () => { + const [category] = this.entityToCategoryTable.get(group).get(version).get(kind); + + category[handlerName].add(handler as any); + }, + ), + () => void this.entityToCategoryTable.get(group)?.get(version)?.delete(kind), + ); + } + + throw new Error("Not providing a version for groups is not supported at this time"); + // This would requiring observing future additions to the second level of the map + // and waiting for them to add the kind + // all wrapped up in disposers + } + + runEntityHandlersFor(entity: CatalogEntity, handlerName: "onContextMenuOpen"): ReturnType; + runEntityHandlersFor(entity: CatalogEntity, handlerName: "onSettingsOpen"): ReturnType; + runEntityHandlersFor(entity: CatalogEntity, handlerName: EntityContextHandlers): ReturnType { + const category = this.getCategoryForEntity(entity) as CatalogCategory; // safe and what it actually is + const res = (entity[handlerName] as any)?.(...EntityContexts[handlerName]()) ?? []; + + console.log(category, res); + + for (const handler of category[handlerName].values()) { + res.push((handler as any)(entity, ...EntityContexts[handlerName]())); + } + + return res.flat(); + } + + runGlobalHandlersFor({ spec }: CatalogCategorySpec, handlerName: "onAddMenuOpen"): ReturnType; + runGlobalHandlersFor({ spec }: CatalogCategorySpec, handlerName: GlobalContextHandlers): ReturnType { + const category = this.categoryIdLookup.get(`${spec.group}/${spec.names.kind}`); + const res = []; + + for (const handler of category[handlerName].values()) { + res.push((handler as any)(...GlobalContexts[handlerName]())); + } + + return res.flat(); + } + + getEntityForData(data: MatchingCatalogEntityData & CatalogEntityKindData): Entity { + const { group, version } = parseApiVersion(data.apiVersion); + + const [, entityClass] = this.entityToCategoryTable.get(group)?.get(version)?.get(data.kind); + const res = new entityClass(data); + + if (res.apiVersion !== data.apiVersion || res.kind !== data.kind) { + throw new TypeError(`CatalogEntity class declared for ${group}/${version}:${data.kind} produced ${res.apiVersion}:${res.kind}`); + } + + return res as Entity; + } + + getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData): CatalogCategorySpec | undefined { + const { group, version } = parseApiVersion(data.apiVersion); + + return this.entityToCategoryTable.get(group)?.get(version)?.get(data.kind)?.[0]; + } +} diff --git a/src/common/catalog/catalog-entity-registry.ts b/src/common/catalog/catalog-entity-registry.ts index 80a6c66a4c..fd004174e9 100644 --- a/src/common/catalog/catalog-entity-registry.ts +++ b/src/common/catalog/catalog-entity-registry.ts @@ -20,10 +20,10 @@ */ import { action, computed, observable, IComputedValue, IObservableArray } from "mobx"; -import type { CatalogEntity } from "./catalog-entity"; -import { iter } from "../utils"; +import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import { cloneJsonObject, iter, Singleton } from "../utils"; -export class CatalogEntityRegistry { +export class CatalogEntityRegistry extends Singleton { protected sources = observable.map>([], { deep: true }); @action addObservableSource(id: string, source: IObservableArray) { @@ -38,8 +38,25 @@ export class CatalogEntityRegistry { this.sources.delete(id); } - @computed get items(): CatalogEntity[] { - return Array.from(iter.flatMap(this.sources.values(), source => source.get())); + @computed get items(): (CatalogEntityData & CatalogEntityKindData)[] { + // This is done to filter out non-serializable items, namely functions + return Array.from( + iter.flatMap( + this.sources.values(), + source => ( + iter.map( + source.get(), + ({ apiVersion, kind, metadata, spec, status }) => ({ + apiVersion, + kind, + metadata: cloneJsonObject(metadata), + spec: cloneJsonObject(spec), + status: cloneJsonObject(status), + }), + ) + ), + ), + ); } getItemsForApiKind(apiVersion: string, kind: string): T[] { @@ -48,5 +65,3 @@ export class CatalogEntityRegistry { return items as T[]; } } - -export const catalogEntityRegistry = new CatalogEntityRegistry(); diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 51660a3d3d..5ecfc547e6 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -18,47 +18,99 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { EventEmitter } from "events"; import { observable } from "mobx"; +import URLParse from "url-parse"; + +export interface ParsedApiVersion { + group: string; + version?: string; +} + +const versionSchema = /^\/(?v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?)$/; + +/** + * Attempts to parse an ApiVersion string or a group string + * @param apiVersionOrGroup A string that should be either of the form `/` or `` for any version + * @param strict if true then will throw an error if `` is not provided + * @default strict = true + * @returns A parsed data + */ +export function parseApiVersion(apiVersionOrGroup: string, strict: false): ParsedApiVersion; +export function parseApiVersion(apiVersionOrGroup: string, strict?: true): Required; + +export function parseApiVersion(apiVersionOrGroup: string, strict?: boolean): ParsedApiVersion { + strict ??= true; + + const parsed = new URLParse(`lens://${apiVersionOrGroup}`); + + if ( + parsed.protocol !== "lens:" + || parsed.hash + || parsed.query + || parsed.auth + || parsed.port + || parsed.password + || parsed.username + ) { + throw new TypeError(`invalid apiVersion string: ${apiVersionOrGroup}`); + } + + if (!parsed.pathname) { + throw new TypeError(`missing version on apiVersion: ${apiVersionOrGroup}`); + } + + const match = parsed.pathname.match(versionSchema); + + if (versionSchema && !match && strict) { + throw new TypeError(`invalid version on apiVersion: ${apiVersionOrGroup}`); + } + + return { + group: parsed.hostname, + version: match?.groups.version, + }; +} type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; type ExtractEntityStatusType = Entity extends CatalogEntity ? Status : never; type ExtractEntitySpecType = Entity extends CatalogEntity ? Spec : never; -export type CatalogEntityConstructor = ( - (new (data: CatalogEntityData< - ExtractEntityMetadataType, - ExtractEntityStatusType, - ExtractEntitySpecType - >) => Entity) -); +export type MatchingCatalogEntityData = CatalogEntityData< + ExtractEntityMetadataType, + ExtractEntityStatusType, + ExtractEntitySpecType +>; + +export type CatalogEntityConstructor = new (data: MatchingCatalogEntityData) => Entity; export interface CatalogCategoryVersion { - name: string; + version: string; entityClass: CatalogEntityConstructor; } export interface CatalogCategorySpec { - group: string; - versions: CatalogCategoryVersion[]; - names: { - kind: string; - }; -} - -export abstract class CatalogCategory extends EventEmitter { - abstract readonly apiVersion: string; - abstract readonly kind: string; - abstract metadata: { + readonly apiVersion: string; + readonly kind: string; + readonly metadata: { name: string; icon: string; }; - abstract spec: CatalogCategorySpec; - public getId(): string { - return `${this.spec.group}/${this.spec.names.kind}`; - } + /** + * It will be a runtime error if any of the instances created through the + * versions don't match the provided `group` and `names.kind` provided here. + */ + readonly spec: { + group: string; + versions: CatalogCategoryVersion[]; + names: { + kind: string; + }; + }; +} + +export function getCatalogCategoryId(category: CatalogCategorySpec): string { + return `${category.spec.group}/${category.spec.names.kind}`; } export interface CatalogEntityMetadata { @@ -71,18 +123,21 @@ export interface CatalogEntityMetadata { } export interface CatalogEntityStatus { - phase: string; + phase?: string; reason?: string; message?: string; active?: boolean; } -export interface CatalogEntityActionContext { +export interface ActionContext { navigate: (url: string) => void; setCommandPaletteContext: (context?: CatalogEntity) => void; } -export interface CatalogEntityContextMenu { +export type ActionHandler = (ctx: ActionContext) => void; + +export interface ContextMenu { + icon: string; title: string; onlyVisibleForSource?: string; // show only if empty or if matches with entity source onClick: () => void | Promise; @@ -91,11 +146,19 @@ export interface CatalogEntityContextMenu { } } -export interface CatalogEntityAddMenu extends CatalogEntityContextMenu { - icon: string; +export interface MenuContext { + navigate: (url: string) => void; } -export interface CatalogEntitySettingsMenu { +export type ContextMenuOpenHandler = (ctx: MenuContext) => ContextMenu[]; +export type AddMenuOpenHandler = (ctx: MenuContext) => ContextMenu[]; + +export type CategoryHandler any> = (entity: CatalogEntity, ...args: Parameters) => ReturnType; + +export interface SettingsContext { +} + +export interface SettingsMenu { group?: string; title: string; components: { @@ -103,19 +166,7 @@ export interface CatalogEntitySettingsMenu { }; } -export interface CatalogEntityContextMenuContext { - navigate: (url: string) => void; - menuItems: CatalogEntityContextMenu[]; -} - -export interface CatalogEntitySettingsContext { - menuItems: CatalogEntityContextMenu[]; -} - -export interface CatalogEntityAddMenuContext { - navigate: (url: string) => void; - menuItems: CatalogEntityAddMenu[]; -} +export type SettingsMenuOpenHandler = (ctx: SettingsContext) => SettingsMenu[]; export type CatalogEntitySpec = Record; @@ -160,8 +211,7 @@ export abstract class CatalogEntity< return this.metadata.name; } - public abstract onRun?(context: CatalogEntityActionContext): void | Promise; - public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise; - public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; - public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; + public onRun?: ActionHandler; + public onContextMenuOpen?: ContextMenuOpenHandler; + public onSettingsOpen?: SettingsMenuOpenHandler; } diff --git a/src/common/cluster-frames.ts b/src/common/cluster-frames.ts deleted file mode 100644 index 794a09d827..0000000000 --- a/src/common/cluster-frames.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import { observable } from "mobx"; - -export type ClusterFrameInfo = { - frameId: number; - processId: number -}; - -export const clusterFrameMap = observable.map(); diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 8f9686be7c..172f714959 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -19,9 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export const clusterActivateHandler = "cluster:activate"; -export const clusterSetFrameIdHandler = "cluster:set-frame-id"; -export const clusterRefreshHandler = "cluster:refresh"; -export const clusterDisconnectHandler = "cluster:disconnect"; -export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; -export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; +export const activate = "cluster:activate"; +export const setFrameId = "cluster:set-frame-id"; +export const refresh = "cluster:refresh"; +export const disconnect = "cluster:disconnect"; +export const kubectlApplyAll = "cluster:kubectl-apply-all"; diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 56432714d5..394362ee22 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -38,7 +38,6 @@ export class ClusterStore extends BaseStore { private static StateChannel = "cluster:state"; @observable activeCluster: ClusterId; - @observable removedClusters = observable.map(); @observable clusters = observable.map(); private static stateRequestChannel = "cluster:states"; @@ -229,7 +228,6 @@ export class ClusterStore extends BaseStore { protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) { const currentClusters = this.clusters.toJS(); const newClusters = new Map(); - const removedClusters = new Map(); // update new clusters for (const clusterModel of clusters) { @@ -247,16 +245,8 @@ export class ClusterStore extends BaseStore { } } - // update removed clusters - currentClusters.forEach(cluster => { - if (!newClusters.has(cluster.id)) { - removedClusters.set(cluster.id, cluster); - } - }); - this.setActive(activeCluster); this.clusters.replace(newClusters); - this.removedClusters.replace(removedClusters); } toJSON(): ClusterStoreModel { diff --git a/src/common/default-categories.ts b/src/common/default-categories.ts new file mode 100644 index 0000000000..e8a934e558 --- /dev/null +++ b/src/common/default-categories.ts @@ -0,0 +1,52 @@ +import { CatalogCategoryRegistry } from "./catalog"; +import { KubernetesCluster, WebLink } from "./catalog-entities"; + +export function registerDefaultCategories() { + const registry = CatalogCategoryRegistry.getInstance(); + + registry.add({ + apiVersion: "catalog.k8slens.dev/v1alpha1", + kind: "CatalogCategory", + metadata: { + name: "Kubernetes Clusters", + icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line + }, + spec: { + group: "entity.k8slens.dev", + versions: [ + { + version: "v1alpha1", + entityClass: KubernetesCluster + } + ], + names: { + kind: "KubernetesCluster" + } + }, + onAddMenuOpen: ({ navigate }) => [{ + icon: "text_snippet", + title: "Add from kubeconfig", + onClick: () => navigate("/add-cluster"), + }], + }); + registry.add({ + apiVersion: "catalog.k8slens.dev/v1alpha1", + kind: "CatalogCategory", + metadata: { + name: "Web Links", + icon: "link" + }, + spec: { + group: "entity.k8slens.dev", + versions: [ + { + version: "v1alpha1", + entityClass: WebLink, + } + ], + names: { + kind: "WebLink", + }, + }, + }); +} diff --git a/src/common/catalog-entities/icons/kubernetes.svg b/src/common/icons/kubernetes.svg similarity index 100% rename from src/common/catalog-entities/icons/kubernetes.svg rename to src/common/icons/kubernetes.svg diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 24750ac4bc..c8ae756ffd 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -22,11 +22,10 @@ // Inter-process communications (main <-> renderer) // https://www.electronjs.org/docs/api/ipc-main // https://www.electronjs.org/docs/api/ipc-renderer +import Electron, { ipcMain, ipcRenderer, remote } from "electron"; -import { ipcMain, ipcRenderer, webContents, remote } from "electron"; -import { toJS } from "mobx"; +import { ClusterFrameInfo, ClusterManager } from "../../main/cluster-manager"; import logger from "../../main/logger"; -import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames"; import type { Disposer } from "../utils"; const subFramesChannel = "ipc:get-sub-frames"; @@ -36,11 +35,11 @@ export async function requestMain(channel: string, ...args: any[]) { } function getSubFrames(): ClusterFrameInfo[] { - return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true }); + return ClusterManager.getInstance().getAllFrameInfo(); } -export function broadcastMessage(channel: string, ...args: any[]) { - const views = (webContents || remote?.webContents)?.getAllWebContents(); +export async function broadcastMessage(channel: string, ...args: any[]) { + const views = (Electron.webContents || remote?.webContents)?.getAllWebContents(); if (!views) return; diff --git a/src/common/utils/extended-map.ts b/src/common/utils/extended-map.ts index 9d3a0dc7b3..92e13ea60b 100644 --- a/src/common/utils/extended-map.ts +++ b/src/common/utils/extended-map.ts @@ -21,7 +21,38 @@ import { action, IEnhancer, IObservableMapInitialValues, ObservableMap } from "mobx"; -export class ExtendedMap extends Map { +export class DuplicateKeyError extends Error { + constructor(public key: any) { + super("Duplicate key in map"); + } +} + +export class StrictMap extends Map { + /** + * @throws if `key` already in map + */ + strictSet(key: K, val: V): this { + if (this.has(key)) { + throw new DuplicateKeyError(key); + } + + return this.set(key, val); + } +} + +export class ExtendedMap extends StrictMap { + static new(): ExtendedMap> { + return new ExtendedMap>(() => new Map()); + } + + static newExtended(getDefault: () => MV): ExtendedMap> { + return new ExtendedMap>(() => new ExtendedMap(getDefault)); + } + + static newExtendedStrict(): ExtendedMap>> { + return new ExtendedMap>>(() => new ExtendedMap>(() => new StrictMap())); + } + constructor(protected getDefault: () => V, entries?: readonly (readonly [K, V])[] | null) { super(entries); } diff --git a/src/extensions/core-api/catalog.ts b/src/extensions/core-api/catalog.ts index 304ab7bbd0..1fa80b5b96 100644 --- a/src/extensions/core-api/catalog.ts +++ b/src/extensions/core-api/catalog.ts @@ -18,17 +18,63 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { + CatalogCategorySpec, + CatalogEntity, + CatalogEntityData, + CatalogEntityKindData, + CatalogEntityRegistry as InternalCatalogEntityRegistry, + MatchingCatalogEntityData, +} from "../../common/catalog"; +import { + CatalogCategoryRegistry as InternalCatalogCategoryRegistry, + CatalogHandler, + CategoryHandlerNames, + CategoryHandlers, + EntityContextHandlers, + GlobalContextHandlers, +} from "../../common/catalog/catalog-category-registry"; +import { Disposer } from "../../common/utils"; - -import { CatalogEntity, catalogEntityRegistry as registry } from "../../common/catalog"; - -export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; export * from "../../common/catalog-entities"; export class CatalogEntityRegistry { - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); + static getItemsForApiKind(apiVersion: string, kind: string): T[] { + return InternalCatalogEntityRegistry.getInstance().getItemsForApiKind(apiVersion, kind); } } -export const catalogEntities = new CatalogEntityRegistry(); +export class CatalogCategoryRegistry { + /** + * Registers a new category + * @param category The category to register + * @throws if the apiVersion and kind conflict with a previously registered category + * @returns a disposer to remove the category + */ + static add(category: CatalogCategorySpec): () => void { + return InternalCatalogCategoryRegistry.getInstance().add(category); + } + + static registerHandler(apiVersion: string, kind: string, handlerName: CategoryHandlerNames, handler: CatalogHandler): Disposer { + return InternalCatalogCategoryRegistry.getInstance().registerHandler(apiVersion, kind, handlerName, handler); + } + + static runEntityHandlersFor(entity: CatalogEntity, handlerName: "onContextMenuOpen"): ReturnType; + static runEntityHandlersFor(entity: CatalogEntity, handlerName: "onSettingsOpen"): ReturnType; + static runEntityHandlersFor(entity: CatalogEntity, handlerName: EntityContextHandlers): ReturnType { + return InternalCatalogCategoryRegistry.getInstance().runEntityHandlersFor(entity, handlerName as any); + } + + static runGlobalHandlersFor(categorySpec: CatalogCategorySpec, handlerName: "onAddMenuOpen"): ReturnType; + static runGlobalHandlersFor(categorySpec: CatalogCategorySpec, handlerName: GlobalContextHandlers): ReturnType { + return InternalCatalogCategoryRegistry.getInstance().runGlobalHandlersFor(categorySpec, handlerName as any); + } + + static getEntityForData(data: MatchingCatalogEntityData & CatalogEntityKindData): Entity { + return InternalCatalogCategoryRegistry.getInstance().getEntityForData(data); + } + + static getCategorySpecForEntity(data: CatalogEntityData & CatalogEntityKindData): CatalogCategorySpec { + return InternalCatalogCategoryRegistry.getInstance().getCategoryForEntity(data); + } +} diff --git a/src/extensions/core-api/utils.ts b/src/extensions/core-api/utils.ts index 78814f9025..f02ce149f7 100644 --- a/src/extensions/core-api/utils.ts +++ b/src/extensions/core-api/utils.ts @@ -19,6 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { Singleton, openExternal } from "../../common/utils"; +export { Singleton, openExternal, disposer, Disposer, ExtendableDisposer } from "../../common/utils"; export { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault"; export { cssNames } from "../../renderer/utils/cssNames"; diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index b6faf2e491..6219e55da7 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -24,7 +24,7 @@ import { action, observable, reaction } from "mobx"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; import type { ProtocolHandlerRegistration } from "./registries"; -import { disposer } from "../common/utils"; +import { Disposer, disposer } from "../common/utils"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -106,28 +106,21 @@ export class LensExtension { } } - async whenEnabled(handlers: () => Promise) { - const disposers: Function[] = []; - const unregisterHandlers = () => { - disposers.forEach(unregister => unregister()); - disposers.length = 0; - }; - const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => { - if (isEnabled) { - const handlerDisposers = await handlers(); + async whenEnabled(handlers: () => Promise) { + const disposers = disposer(); - disposers.push(...handlerDisposers); - } else { - unregisterHandlers(); - } - }, { - fireImmediately: true - }); - - return () => { - unregisterHandlers(); - cancelReaction(); - }; + return disposer( + disposers, + reaction(() => this.isEnabled, async (isEnabled) => { + if (isEnabled) { + disposers.push(...(await handlers())); + } else { + disposers(); + } + }, { + fireImmediately: true + }) + ); } protected onActivate(): void { diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index 7e0ac71707..9823364ed1 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -22,7 +22,7 @@ import { LensExtension } from "./lens-extension"; import { WindowManager } from "../main/window-manager"; import { getExtensionPageUrl } from "./registries/page-registry"; -import { CatalogEntity, catalogEntityRegistry } from "../common/catalog"; +import { CatalogEntity, CatalogEntityRegistry } from "../common/catalog"; import type { IObservableArray } from "mobx"; import type { MenuRegistration } from "./registries"; @@ -41,10 +41,10 @@ export class LensMainExtension extends LensExtension { } addCatalogSource(id: string, source: IObservableArray) { - catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source); + CatalogEntityRegistry.getInstance().addObservableSource(`${this.name}:${id}`, source); } removeCatalogSource(id: string) { - catalogEntityRegistry.removeSource(`${this.name}:${id}`); + CatalogEntityRegistry.getInstance().removeSource(`${this.name}:${id}`); } } diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index ce7fd4e10f..7c3f66dac0 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -21,27 +21,24 @@ import { reaction, toJS } from "mobx"; import { broadcastMessage, ipcMainOn } from "../common/ipc"; -import type { CatalogEntityRegistry} from "../common/catalog"; +import { disposer, Singleton } from "../common/utils"; +import { CatalogEntityRegistry } from "../common/catalog"; + import "../common/catalog-entities/kubernetes-cluster"; -import { disposer } from "../common/utils"; -export class CatalogPusher { - static init(catalog: CatalogEntityRegistry) { - new CatalogPusher(catalog).init(); - } - - private constructor(private catalog: CatalogEntityRegistry) {} +export class CatalogPusher extends Singleton { init() { return disposer( - reaction(() => toJS(this.catalog.items, { recurseEverything: true }), (items) => { + reaction(() => toJS(CatalogEntityRegistry.getInstance().items, { recurseEverything: true }), (items) => { + console.log("pushing new items"); broadcastMessage("catalog:items", items); }, { fireImmediately: true, }), ipcMainOn("catalog:broadcast", () => { - broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true })); - }) + broadcastMessage("catalog:items", toJS(CatalogEntityRegistry.getInstance().items, { recurseEverything: true })); + }), ); } } diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 6f89e12c46..79eb88dafe 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -20,13 +20,13 @@ */ import { ObservableMap } from "mobx"; -import type { CatalogEntity } from "../../../common/catalog"; import { loadFromOptions } from "../../../common/kube-helpers"; import type { Cluster } from "../../cluster"; import { computeDiff, configToModels } from "../kubeconfig-sync"; import mockFs from "mock-fs"; import fs from "fs"; import { ClusterStore } from "../../../common/cluster-store"; +import type { KubernetesCluster } from "../../../common/catalog-entities"; describe("kubeconfig-sync.source tests", () => { beforeEach(() => { @@ -79,7 +79,7 @@ describe("kubeconfig-sync.source tests", () => { describe("computeDiff", () => { it("should leave an empty source empty if there are no entries", () => { const contents = ""; - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; computeDiff(contents, rootSource, filePath); @@ -114,7 +114,7 @@ describe("kubeconfig-sync.source tests", () => { }], currentContext: "foobar" }); - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; fs.writeFileSync(filePath, contents); @@ -157,7 +157,7 @@ describe("kubeconfig-sync.source tests", () => { }], currentContext: "foobar" }); - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; fs.writeFileSync(filePath, contents); @@ -211,7 +211,7 @@ describe("kubeconfig-sync.source tests", () => { }], currentContext: "foobar" }); - const rootSource = new ObservableMap(); + const rootSource = new ObservableMap(); const filePath = "/bar"; fs.writeFileSync(filePath, contents); diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index 61a65e69a4..57f6abfa77 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -18,9 +18,6 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { action, observable, IComputedValue, computed, ObservableMap, runInAction } from "mobx"; -import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog"; import { watch } from "chokidar"; import fs from "fs"; import fse from "fs-extra"; @@ -29,22 +26,32 @@ import { Disposer, ExtendedObservableMap, iter, Singleton, storedKubeConfigFolde import logger from "../logger"; import type { KubeConfig } from "@kubernetes/client-node"; import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers"; -import { Cluster } from "../cluster"; -import { catalogEntityFromCluster } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; -import { ClusterStore } from "../../common/cluster-store"; import type { UpdateClusterModel } from "../../common/cluster-types"; -import { createHash } from "crypto"; +import { observable, ObservableMap, computed, action, runInAction } from "mobx"; +import { CatalogEntityRegistry } from "../../common/catalog"; +import { KubernetesCluster } from "../../common/catalog-entities"; +import { Cluster } from "../cluster"; const logPrefix = "[KUBECONFIG-SYNC]:"; export class KubeconfigSyncManager extends Singleton { - protected sources = observable.map, Disposer]>(); + protected sources = observable.map>, Disposer]>(); protected syncing = false; protected syncListDisposer?: Disposer; protected static readonly syncName = "lens:kube-sync"; + protected items = computed(() => ( + Array.from(iter.flatMap( + this.sources.values(), + ([sources]) => iter.flatMap( + sources.values(), + source => source.values(), + ), + )) + )); + @action startSync(): void { if (this.syncing) { @@ -55,12 +62,7 @@ export class KubeconfigSyncManager extends Singleton { logger.info(`${logPrefix} starting requested syncs`); - catalogEntityRegistry.addComputedSource(KubeconfigSyncManager.syncName, computed(() => ( - Array.from(iter.flatMap( - this.sources.values(), - ([entities]) => entities.get() - )) - ))); + CatalogEntityRegistry.getInstance().addComputedSource(KubeconfigSyncManager.syncName, this.items); // This must be done so that c&p-ed clusters are visible this.startNewSync(storedKubeConfigFolder()); @@ -89,7 +91,7 @@ export class KubeconfigSyncManager extends Singleton { this.stopOldSync(filePath); } - catalogEntityRegistry.removeSource(KubeconfigSyncManager.syncName); + CatalogEntityRegistry.getInstance().removeSource(KubeconfigSyncManager.syncName); this.syncing = false; } @@ -142,8 +144,7 @@ export function configToModels(config: KubeConfig, filePath: string): UpdateClus return validConfigs; } -type RootSourceValue = [Cluster, CatalogEntity]; -type RootSource = ObservableMap; +type RootSource = ObservableMap; // exported for testing export function computeDiff(contents: string, source: RootSource, filePath: string): void { @@ -154,12 +155,11 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); - for (const [contextName, value] of source) { + for (const [contextName] of source) { const model = models.get(contextName); - // remove and disconnect clusters that were removed from the config + // remove clusters that were removed from the config if (!model) { - value[0].disconnect(); source.delete(contextName); logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); continue; @@ -169,31 +169,32 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri // Probably should make it so that cluster keeps a copy of the config in its memory and // diff against that - // or update the model and mark it as not needed to be added - value[0].updateModel(model); + // or mark it as not needed to be added models.delete(contextName); logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName }); } for (const [contextName, model] of models) { // add new clusters to the source - try { - const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); - const cluster = ClusterStore.getInstance().getById(clusterId) || new Cluster({ ...model, id: clusterId}); - - if (!cluster.apiUrl) { - throw new Error("Cluster constructor failed, see above error"); + source.set(contextName, new KubernetesCluster({ + metadata: { + uid: Cluster.getDeteministicId(model), + name: model.contextName, + source: "local", + labels: { + file: filePath, + } + }, + spec: { + kubeconfigPath: model.kubeConfigPath, + kubeconfigContext: model.contextName + }, + status: { + phase: "disconnected", } + })); - const entity = catalogEntityFromCluster(cluster); - - entity.metadata.labels.file = filePath; - source.set(contextName, [cluster, entity]); - - logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName }); - } catch (error) { - logger.warn(`${logPrefix} Failed to create cluster from model: ${error}`, { filePath, contextName }); - } + logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName }); } } catch (error) { logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath }); @@ -242,15 +243,14 @@ function diffChangedConfig(filePath: string, source: RootSource): Disposer { return cleanup; } -async function watchFileChanges(filePath: string): Promise<[IComputedValue, Disposer]> { +async function watchFileChanges(filePath: string): Promise<[ExtendedObservableMap>, Disposer]> { const stat = await fse.stat(filePath); // traverses symlinks, is a race condition const watcher = watch(filePath, { followSymlinks: true, depth: stat.isDirectory() ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) disableGlobbing: true, }); - const rootSource = new ExtendedObservableMap>(observable.map); - const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); + const rootSource = new ExtendedObservableMap>(observable.map); const stoppers = new Map(); watcher @@ -268,5 +268,5 @@ async function watchFileChanges(filePath: string): Promise<[IComputedValue logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); - return [derivedSource, () => watcher.close()]; + return [rootSource, () => watcher.close()]; } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 33ad86fa28..8546e9b93b 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -18,129 +18,161 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import type http from "http"; import { ipcMain } from "electron"; -import { action, autorun, reaction, toJS } from "mobx"; -import { ClusterStore } from "../common/cluster-store"; -import { getClusterIdFromHost } from "../common/cluster-types"; -import type { Cluster } from "./cluster"; -import logger from "./logger"; +import { action, computed, observable, reaction } from "mobx"; +import { CatalogEntityRegistry } from "../common/catalog"; +import type { KubernetesCluster, KubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; +import * as ClusterChannels from "../common/cluster-ipc"; +import { appEventBus } from "../common/event-bus"; +import { iter, noop, Singleton } from "../common/utils"; import { apiKubePrefix } from "../common/vars"; -import { Singleton } from "../common/utils"; -import { catalogEntityRegistry } from "../common/catalog"; -import { KubernetesCluster, KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; +import { Cluster } from "./cluster"; +import logger from "./logger"; +import { ResourceApplier } from "./resource-applier"; +import type http from "http"; +import { ClusterId, getClusterIdFromHost } from "../common/cluster-types"; + +export type ClusterFrameInfo = { + frameId: number; + processId: number +}; export class ClusterManager extends Singleton { + protected clusterInstances = observable.map(); + protected clusterFrameMap = observable.map(); + constructor() { super(); - reaction(() => toJS(ClusterStore.getInstance().clustersList, { recurseEverything: true }), () => { - this.updateCatalog(ClusterStore.getInstance().clustersList); - }, { fireImmediately: true }); - - reaction(() => catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { + reaction(() => CatalogEntityRegistry.getInstance().getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { this.syncClustersFromCatalog(entities); }); - - // auto-stop removed clusters - autorun(() => { - const removedClusters = Array.from(ClusterStore.getInstance().removedClusters.values()); - - if (removedClusters.length > 0) { - const meta = removedClusters.map(cluster => cluster.getMeta()); - - logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); - removedClusters.forEach(cluster => cluster.disconnect()); - ClusterStore.getInstance().removedClusters.clear(); - } - }, { - delay: 250 - }); - - ipcMain.on("network:offline", () => { this.onNetworkOffline(); }); - ipcMain.on("network:online", () => { this.onNetworkOnline(); }); + ipcMain.on("network:offline", this.onNetworkOffline); + ipcMain.on("network:online", this.onNetworkOnline); + ipcMain.handle(ClusterChannels.activate, this.handleClusterActivate); + ipcMain.handle(ClusterChannels.setFrameId, this.handleClusteSetFrameId); + ipcMain.handle(ClusterChannels.refresh, this.handleClusterRefresh); + ipcMain.handle(ClusterChannels.disconnect, this.handleClusterDisconnect); + ipcMain.handle(ClusterChannels.kubectlApplyAll, this.handleKubectlApplyAll); } - @action protected updateCatalog(clusters: Cluster[]) { - for (const cluster of clusters) { - const index = catalogEntityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id); - - if (index !== -1) { - const entity = catalogEntityRegistry.items[index] as KubernetesCluster; - - entity.status.phase = cluster.disconnected ? "disconnected" : "connected"; - entity.status.active = !cluster.disconnected; - - if (cluster.preferences?.clusterName) { - entity.metadata.name = cluster.preferences.clusterName; - } - - entity.spec.metrics ||= { source: "local" }; - - if (entity.spec.metrics.source === "local") { - const prometheus: KubernetesClusterPrometheusMetrics = entity.spec?.metrics?.prometheus || {}; - - prometheus.type = cluster.preferences.prometheusProvider?.type; - prometheus.address = cluster.preferences.prometheus; - entity.spec.metrics.prometheus = prometheus; - } - - catalogEntityRegistry.items.splice(index, 1, entity); - } - } + /** + * Is a computed mapping between `frameId`'s and their associated `ClusterFrameInfo` + */ + @computed get frameMapById(): Map { + return new Map(iter.map(this.clusterFrameMap.values(), info => [info.frameId, info.processId])); } @action syncClustersFromCatalog(entities: KubernetesCluster[]) { for (const entity of entities) { - const cluster = ClusterStore.getInstance().getById(entity.metadata.uid); + const cluster = this.clusterInstances.get(entity.metadata.uid); if (!cluster) { - ClusterStore.getInstance().addCluster({ + this.clusterInstances.set(entity.metadata.uid, new Cluster({ id: entity.metadata.uid, preferences: { clusterName: entity.metadata.name }, kubeConfigPath: entity.spec.kubeconfigPath, contextName: entity.spec.kubeconfigContext + })); + + // This is done so that the push to renderer is updated as necessary + // This also should prevent extensions from trying to set this themselves + // in the future. + Object.defineProperty(entity, "status", { + enumerable: true, + configurable: false, + writable: false, + get(): KubernetesClusterStatus { + return { + phase: cluster.disconnected ? "disconnected" : "connected", + active: !cluster.disconnected, + }; + } }); } else { cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; - - entity.status = { - phase: cluster.disconnected ? "disconnected" : "connected", - active: !cluster.disconnected - }; } } } - protected onNetworkOffline() { + protected onNetworkOffline = () => { logger.info("[CLUSTER-MANAGER]: network is offline"); - ClusterStore.getInstance().clustersList.forEach((cluster) => { + + for (const cluster of this.clusterInstances.values()) { if (!cluster.disconnected) { cluster.online = false; cluster.accessible = false; - cluster.refreshConnectionStatus().catch((e) => e); + cluster.refreshConnectionStatus().catch(noop); } - }); + } + }; + + protected onNetworkOnline = () => { + logger.info("[CLUSTER-MANAGER]: network is online"); + + for (const cluster of this.clusterInstances.values()) { + if (!cluster.disconnected) { + cluster.refreshConnectionStatus().catch(noop); + } + } + }; + + protected handleClusterActivate = (event: Electron.IpcMainInvokeEvent, clusterId: ClusterId, force = false) => { + return this.clusterInstances.get(clusterId)?.activate(force); + }; + + protected handleClusteSetFrameId = ({ frameId, processId }: Electron.IpcMainInvokeEvent, clusterId: ClusterId) => { + const cluster = this.clusterInstances.get(clusterId); + + if (!cluster) { + return; + } + + this.clusterFrameMap.set(cluster.id, { frameId, processId }); + + return cluster.pushState(); + }; + + protected handleClusterRefresh = (event: Electron.IpcMainInvokeEvent, clusterId: ClusterId) => { + return this.clusterInstances.get(clusterId)?.refresh({ refreshMetadata: true }); + }; + + protected handleClusterDisconnect = (event: Electron.IpcMainInvokeEvent, clusterId: ClusterId) => { + return this.clusterInstances.get(clusterId)?.disconnect(); + }; + + protected handleKubectlApplyAll = (event: Electron.IpcMainInvokeEvent, clusterId: ClusterId, resources: string[]) => { + appEventBus.emit({ name: "cluster", action: "kubectl-apply-all" }); + + const cluster = this.clusterInstances.get(clusterId); + + if (!cluster) { + throw new Error(`${clusterId} is not a valid ID`); + } + + return ResourceApplier.new(cluster).kubectlApplyAll(resources); + }; + + getFrameInfoByClusterId(clusterId: ClusterId): ClusterFrameInfo { + return this.clusterFrameMap.get(clusterId); } - protected onNetworkOnline() { - logger.info("[CLUSTER-MANAGER]: network is online"); - ClusterStore.getInstance().clustersList.forEach((cluster) => { - if (!cluster.disconnected) { - cluster.refreshConnectionStatus().catch((e) => e); - } - }); + getFrameProcessIdById(frameId: number): number { + return this.frameMapById.get(frameId); + } + + getAllFrameInfo(): ClusterFrameInfo[] { + return Array.from(this.clusterFrameMap.values()); } stop() { - ClusterStore.getInstance().clusters.forEach((cluster: Cluster) => { + for (const cluster of this.clusterInstances.values()) { cluster.disconnect(); - }); + } } getClusterForRequest(req: http.IncomingMessage): Cluster { @@ -150,45 +182,20 @@ export class ClusterManager extends Singleton { if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; - cluster = ClusterStore.getInstance().getById(clusterId); + cluster = this.clusterInstances.get(clusterId); if (cluster) { // we need to swap path prefix so that request is proxied to kube api req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); } } else if (req.headers["x-cluster-id"]) { - cluster = ClusterStore.getInstance().getById(req.headers["x-cluster-id"].toString()); + cluster = this.clusterInstances.get(req.headers["x-cluster-id"].toString()); } else { const clusterId = getClusterIdFromHost(req.headers.host); - cluster = ClusterStore.getInstance().getById(clusterId); + cluster = this.clusterInstances.get(clusterId); } return cluster; } } - -export function 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.distribution, - } - }, - spec: { - kubeconfigPath: cluster.kubeConfigPath, - kubeconfigContext: cluster.contextName - }, - status: { - phase: cluster.disconnected ? "disconnected" : "connected", - reason: "", - message: "", - active: !cluster.disconnected - } - })); -} diff --git a/src/main/cluster.ts b/src/main/cluster.ts index a6fa888d30..a4fbe9918f 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -18,20 +18,21 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - +import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; +import { createHash } from "crypto"; import { ipcMain } from "electron"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; +import plimit from "p-limit"; + import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; -import { ContextHandler } from "./context-handler"; -import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; -import { Kubectl } from "./kubectl"; -import { KubeconfigManager } from "./kubeconfig-manager"; import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; -import logger from "./logger"; -import { VersionDetector } from "./cluster-detectors/version-detector"; import { detectorRegistry } from "./cluster-detectors/detector-registry"; -import plimit from "p-limit"; +import { VersionDetector } from "./cluster-detectors/version-detector"; +import { ContextHandler } from "./context-handler"; +import { KubeconfigManager } from "./kubeconfig-manager"; +import { Kubectl } from "./kubectl"; +import logger from "./logger"; import type { ClusterModel, ClusterState, ClusterId, ClusterPreferences, ClusterMetadata, ClusterPrometheusPreferences, UpdateClusterModel, ClusterRefreshOptions } from "../common/cluster-types"; import { ClusterStatus } from "../common/cluster-types"; @@ -41,6 +42,10 @@ import { ClusterStatus } from "../common/cluster-types"; * @beta */ export class Cluster implements ClusterModel, ClusterState { + public static getDeteministicId(model: UpdateClusterModel): ClusterId { + return createHash("md5").update(`${model.kubeConfigPath}:${model.contextName}`).digest("hex"); + } + /** Unique id for a cluster */ public readonly id: ClusterId; /** diff --git a/src/main/index.ts b/src/main/index.ts index 32534cc7b8..4644d0c269 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,7 @@ import "../common/system-ca"; import "../common/prometheus-providers"; +import "./initilizers"; import * as Mobx from "mobx"; import * as LensExtensions from "../extensions/core-api"; import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; @@ -50,7 +51,6 @@ import { initGetSubFramesHandler } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { CatalogPusher } from "./catalog-pusher"; -import { catalogEntityRegistry } from "../common/catalog"; import { HotbarStore } from "../common/hotbar-store"; import { HelmRepoManager } from "./helm/helm-repo-manager"; import { KubeconfigSyncManager } from "./catalog-sources"; @@ -59,6 +59,8 @@ import { initIpcMainHandlers } from "./initializers/ipc-handlers"; import { Router } from "./router"; import { initMenu } from "./menu"; import { initTray } from "./tray"; +import { CatalogCategoryRegistry, CatalogEntityRegistry } from "../common/catalog"; +import { registerDefaultCategories } from "../common/default-categories"; const workingDir = path.join(app.getPath("appData"), appName); const cleanup = disposer(); @@ -127,6 +129,10 @@ app.on("ready", async () => { registerFileProtocol("static", __static); + CatalogCategoryRegistry.createInstance(); + registerDefaultCategories(); + CatalogEntityRegistry.createInstance(); + const userStore = UserStore.createInstance(); const clusterStore = ClusterStore.createInstance(); const hotbarStore = HotbarStore.createInstance(); @@ -197,7 +203,7 @@ app.on("ready", async () => { } ipcMain.on(IpcRendererNavigationEvents.LOADED, () => { - CatalogPusher.init(catalogEntityRegistry); + CatalogPusher.createInstance().init(); startUpdateChecking(); LensProtocolRouterMain .getInstance() diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 8e6e3177e7..311275a173 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -31,7 +31,11 @@ import { appEventBus } from "../common/event-bus"; import { cloneJsonObject } from "../common/utils"; export class ResourceApplier { - constructor(protected cluster: Cluster) { + static new(cluster: Cluster) { + return new ResourceApplier(cluster); + } + + protected constructor(protected cluster: Cluster) { } async apply(resource: KubernetesObject | any): Promise { diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index ded4d8faec..e65c33d7f1 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -26,10 +26,10 @@ import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/event-bus"; import { ipcMainOn } from "../common/ipc"; import { Singleton } from "../common/utils"; -import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; -import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; -import logger from "./logger"; import { productName } from "../common/vars"; +import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; +import { ClusterManager } from "./cluster-manager"; +import logger from "./logger"; import { LensProxy } from "./proxy/lens-proxy"; export class WindowManager extends Singleton { @@ -135,34 +135,33 @@ export class WindowManager extends Singleton { return this.mainWindow; } - sendToView({ channel, frameInfo, data = [] }: { channel: string, frameInfo?: ClusterFrameInfo, data?: any[] }) { - if (frameInfo) { - this.mainWindow.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data); - } else { - this.mainWindow.webContents.send(channel, ...data); - } - } - async navigate(url: string, frameId?: number) { await this.ensureMainWindow(); - const frameInfo = Array.from(clusterFrameMap.values()).find((frameInfo) => frameInfo.frameId === frameId); - const channel = frameInfo - ? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER - : IpcRendererNavigationEvents.NAVIGATE_IN_APP; + if (frameId === undefined) { + const processId = ClusterManager.getInstance().getFrameProcessIdById(frameId); - this.sendToView({ - channel, - frameInfo, - data: [url], - }); + this.mainWindow.webContents.sendToFrame( + [processId, frameId], + IpcRendererNavigationEvents.NAVIGATE_IN_APP, + url + ); + } else { + this.mainWindow.webContents.send( + IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, + url + ); + } } reload() { - const frameInfo = clusterFrameMap.get(this.activeClusterId); + const frameInfo = ClusterManager.getInstance().getFrameInfoByClusterId(this.activeClusterId); if (frameInfo) { - this.sendToView({ channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo }); + this.mainWindow.webContents.sendToFrame( + [frameInfo.processId, frameInfo.frameId], + IpcRendererNavigationEvents.RELOAD_PAGE, + ); } else { webContents.getFocusedWebContents()?.reload(); } diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index 7cc208ad55..f6a4c3edff 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -21,12 +21,15 @@ import { CatalogEntityRegistry } from "../catalog-entity-registry"; import "../../../common/catalog-entities"; -import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; describe("CatalogEntityRegistry", () => { describe("updateItems", () => { + beforeEach(() => { + CatalogEntityRegistry.resetInstance(); + }); + it("adds new catalog item", () => { - const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalog = CatalogEntityRegistry.createInstance(); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -65,7 +68,7 @@ describe("CatalogEntityRegistry", () => { }); it("updates existing items", () => { - const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalog = CatalogEntityRegistry.createInstance(); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -93,7 +96,7 @@ describe("CatalogEntityRegistry", () => { }); it("removes deleted items", () => { - const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalog = CatalogEntityRegistry.createInstance(); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", diff --git a/src/renderer/api/catalog-category-registry.ts b/src/renderer/api/catalog-category-registry.ts index a345de5250..f6af7e1d84 100644 --- a/src/renderer/api/catalog-category-registry.ts +++ b/src/renderer/api/catalog-category-registry.ts @@ -19,4 +19,4 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { catalogCategoryRegistry } from "../../common/catalog"; +export { CatalogCategoryRegistry } from "../../common/catalog"; diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 7cbaf65777..9ddeaaf541 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -18,18 +18,24 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { action, observable } from "mobx"; -import { broadcastMessage, ipcRendererOn } from "../../common/ipc"; -import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; -export class CatalogEntityRegistry { +import { action, observable } from "mobx"; + +import { + CatalogCategoryRegistry, + CatalogCategorySpec, + CatalogEntity, + CatalogEntityData, + CatalogEntityKindData, +} from "../../common/catalog"; +import { broadcastMessage, ipcRendererOn } from "../../common/ipc"; +import { Singleton } from "../utils"; + +export class CatalogEntityRegistry extends Singleton { @observable protected _items: CatalogEntity[] = observable.array([], { deep: true }); @observable protected _activeEntity: CatalogEntity; - constructor(private categoryRegistry: CatalogCategoryRegistry) {} - init() { ipcRendererOn("catalog:items", (ev, items: (CatalogEntityData & CatalogEntityKindData)[]) => { this.updateItems(items); @@ -38,7 +44,9 @@ export class CatalogEntityRegistry { } @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { - this._items = items.map(data => this.categoryRegistry.getEntityForData(data)); + const registry = CatalogCategoryRegistry.getInstance(); + + this._items = items.map(data => registry.getEntityForData(data)); } set activeEntity(entity: CatalogEntity) { @@ -46,6 +54,8 @@ export class CatalogEntityRegistry { } get activeEntity() { + console.log(this._activeEntity); + return this._activeEntity; } @@ -63,12 +73,10 @@ export class CatalogEntityRegistry { return items as T[]; } - getItemsForCategory(category: CatalogCategory): T[] { - const supportedVersions = category.spec.versions.map((v) => `${category.spec.group}/${v.name}`); + getItemsForCategory(category: CatalogCategorySpec): T[] { + const supportedVersions = category.spec.versions.map((v) => `${category.spec.group}/${v.version}`); const items = this._items.filter((item) => supportedVersions.includes(item.apiVersion) && item.kind === category.spec.names.kind); return items as T[]; } } - -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); diff --git a/src/renderer/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts index 1a0154defe..b4084f08d7 100644 --- a/src/renderer/api/catalog-entity.ts +++ b/src/renderer/api/catalog-entity.ts @@ -23,19 +23,21 @@ import { navigate } from "../navigation"; import { CommandRegistry } from "../../extensions/registries"; import type { CatalogEntity } from "../../common/catalog"; -export { CatalogCategory, CatalogEntity } from "../../common/catalog"; +export { CatalogEntity } from "../../common/catalog"; export type { + CatalogCategorySpec, CatalogEntityData, CatalogEntityKindData, - CatalogEntityActionContext, - CatalogEntityAddMenuContext, - CatalogEntityAddMenu, - CatalogEntityContextMenu, - CatalogEntityContextMenuContext, + ActionContext, + ActionHandler, + MenuContext, + ContextMenu, + AddMenuOpenHandler, + ContextMenuOpenHandler, } from "../../common/catalog"; export const catalogEntityRunContext = { - navigate: (url: string) => navigate(url), + navigate, setCommandPaletteContext: (entity?: CatalogEntity) => { CommandRegistry.getInstance().activeEntity = entity; } diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index db1f39c284..0c0c06a0f0 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -43,7 +43,9 @@ import { ThemeStore } from "./theme.store"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; import { DefaultProps } from "./mui-base-theme"; -import { initCommandRegistry, initEntitySettingsRegistry, initKubeObjectMenuRegistry, initRegistries, initWelcomeMenuRegistry, intiKubeObjectDetailRegistry } from "./initializers"; +import * as initializers from "./initializers"; +import { CatalogCategoryRegistry, CatalogEntityRegistry } from "../common/catalog"; +import { registerDefaultCategories } from "../common/default-categories"; /** * If this is a development buid, wait a second to attach @@ -75,12 +77,16 @@ export async function bootstrap(App: AppComponent) { await attachChromeDebugger(); rootElem.classList.toggle("is-mac", isMac); - initRegistries(); - initCommandRegistry(); - initEntitySettingsRegistry(); - initKubeObjectMenuRegistry(); - intiKubeObjectDetailRegistry(); - initWelcomeMenuRegistry(); + initializers.initRegistries(); + initializers.initCommandRegistry(); + initializers.initEntitySettingsRegistry(); + initializers.initKubeObjectMenuRegistry(); + initializers.intiKubeObjectDetailRegistry(); + initializers.initWelcomeMenuRegistry(); + + CatalogCategoryRegistry.createInstance(); + registerDefaultCategories(); + CatalogEntityRegistry.createInstance(); ExtensionLoader.createInstance().init(); ExtensionDiscovery.createInstance().init(); diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx index af6b3ebbbb..218dbc0453 100644 --- a/src/renderer/components/+catalog/catalog-add-button.tsx +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -26,32 +26,22 @@ import { Icon } from "../icon"; import { disposeOnUnmount, observer } from "mobx-react"; import { observable, reaction } from "mobx"; import { autobind } from "../../../common/utils"; -import type { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityAddMenu } from "../../api/catalog-entity"; -import { EventEmitter } from "events"; -import { navigate } from "../../navigation"; +import { CatalogCategoryRegistry } from "../../api/catalog-category-registry"; +import type { CatalogCategorySpec, ContextMenu } from "../../api/catalog-entity"; export type CatalogAddButtonProps = { - category: CatalogCategory + category: CatalogCategorySpec, }; @observer export class CatalogAddButton extends React.Component { @observable protected isOpen = false; - protected menuItems = observable.array([]); + @observable protected menuItems: ContextMenu[] = []; componentDidMount() { disposeOnUnmount(this, [ reaction(() => this.props.category, (category) => { - this.menuItems.clear(); - - if (category && category instanceof EventEmitter) { - const context: CatalogEntityAddMenuContext = { - navigate: (url: string) => navigate(url), - menuItems: this.menuItems - }; - - category.emit("onCatalogAddMenu", context); - } + this.menuItems = CatalogCategoryRegistry.getInstance().runGlobalHandlersFor(category, "onAddMenuOpen"); }, { fireImmediately: true }) ]); } diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts index 3bd3dbacfa..b559a846da 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -18,13 +18,11 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { action, computed, IReactionDisposer, observable, reaction } from "mobx"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity"; +import { computed, IReactionDisposer, observable, reaction } from "mobx"; +import type { CatalogEntity, ActionContext, MenuContext, CatalogCategorySpec } from "../../api/catalog-entity"; +import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import { ItemObject, ItemStore } from "../../item.store"; import { autobind } from "../../utils"; -import { CatalogCategory } from "../../../common/catalog"; export class CatalogEntityItem implements ItemObject { constructor(public entity: CatalogEntity) {} @@ -74,26 +72,27 @@ export class CatalogEntityItem implements ItemObject { ]; } - onRun(ctx: CatalogEntityActionContext) { + onRun(ctx: ActionContext) { this.entity.onRun(ctx); } - @action - async onContextMenuOpen(ctx: any) { + onContextMenuOpen(ctx: MenuContext) { return this.entity.onContextMenuOpen(ctx); } } @autobind() export class CatalogEntityStore extends ItemStore { - @observable activeCategory?: CatalogCategory; + @observable activeCategory?: CatalogCategorySpec; @computed get entities() { + const registry = CatalogEntityRegistry.getInstance(); + if (!this.activeCategory) { - return catalogEntityRegistry.items.map(entity => new CatalogEntityItem(entity)); + return registry.items.map(entity => new CatalogEntityItem(entity)); } - return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); + return registry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); } watch() { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 63239eb5d1..f32916a82d 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -25,41 +25,43 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; import { action, observable, reaction } from "mobx"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; -import { navigate } from "../../navigation"; import { kebabCase } from "lodash"; import { PageLayout } from "../layout/page-layout"; import { MenuItem, MenuActions } from "../menu"; -import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; +import { Icon } from "../icon"; +import { catalogEntityRunContext } from "../../api/catalog-entity"; import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { autobind } from "../../utils"; import { ConfirmDialog } from "../confirm-dialog"; import { Tab, Tabs } from "../tabs"; -import { catalogCategoryRegistry } from "../../../common/catalog"; +import { CatalogCategoryRegistry, CatalogCategorySpec, ContextMenu } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; +import { navigate } from "../../navigation"; enum sortBy { name = "name", source = "source", status = "status" } + +function getId({ spec }: CatalogCategorySpec): string { + return `${spec.group}/${spec.names.kind}`; +} + @observer export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; - @observable.deep private contextMenu: CatalogEntityContextMenuContext; + @observable private menuItems: ContextMenu[] = []; @observable activeTab?: string; async componentDidMount() { - this.contextMenu = { - menuItems: [], - navigate: (url: string) => navigate(url) - }; this.catalogEntityStore = new CatalogEntityStore(); disposeOnUnmount(this, [ this.catalogEntityStore.watch(), - reaction(() => catalogCategoryRegistry.items, (items) => { + reaction(() => CatalogCategoryRegistry.getInstance().items, (items) => { if (!this.activeTab && items.length > 0) { - this.activeTab = items[0].getId(); + this.activeTab = getId(items[0]); this.catalogEntityStore.activeCategory = items[0]; } }, { fireImmediately: true }) @@ -74,7 +76,7 @@ export class Catalog extends React.Component { item.onRun(catalogEntityRunContext); } - onMenuItemClick(menuItem: CatalogEntityContextMenu) { + onMenuItemClick(menuItem: ContextMenu) { if (menuItem.confirm) { ConfirmDialog.open({ okButtonProps: { @@ -91,16 +93,15 @@ export class Catalog extends React.Component { } } - get categories() { - return catalogCategoryRegistry.items; - } - @action onTabChange = (tabId: string | null) => { - const activeCategory = this.categories.find(category => category.getId() === tabId); + const activeCategory = CatalogCategoryRegistry.getInstance().getById(tabId); this.catalogEntityStore.activeCategory = activeCategory; - this.activeTab = activeCategory?.getId(); + + if (activeCategory) { + this.activeTab = `${activeCategory.spec.group}/${activeCategory.spec.names.kind}`; + } }; renderNavigation() { @@ -115,14 +116,16 @@ export class Catalog extends React.Component { data-testid="*-tab" /> { - this.categories.map(category => ( - - )) + CatalogCategoryRegistry.getInstance() + .items + .map(category => ( + + )) } @@ -131,10 +134,13 @@ export class Catalog extends React.Component { @autobind() renderItemMenu(item: CatalogEntityItem) { - const menuItems = this.contextMenu.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === item.entity.metadata.source); + const menuItems = this.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === item.entity.metadata.source); return ( - item.onContextMenuOpen(this.contextMenu)}> + this.menuItems = item.onContextMenuOpen({ navigate })} onClose={() => this.menuItems = []}> + this.addToHotbar(item) }> + Add to Hotbar + { menuItems.map((menuItem, index) => ( this.onMenuItemClick(menuItem)}> diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index e33c4b3d41..5f0946b9c6 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -29,7 +29,7 @@ import { PageLayout } from "../layout/page-layout"; import { navigation } from "../../navigation"; import { Tabs, Tab } from "../tabs"; import type { CatalogEntity } from "../../api/catalog-entity"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import { EntitySettingRegistry } from "../../../extensions/registries"; import type { EntitySettingsRouteParams } from "../../../common/routes"; import { groupBy } from "lodash"; @@ -46,7 +46,7 @@ export class EntitySettings extends React.Component { } get entity(): CatalogEntity { - return catalogEntityRegistry.getById(this.entityId); + return CatalogEntityRegistry.getInstance().getById(this.entityId); } get menuItems() { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index fcc8f6f2e5..0553c89563 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -18,55 +18,53 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; +import React from "react"; import { Redirect, Route, Router, Switch } from "react-router"; -import { history } from "../navigation"; -import { Notifications } from "./notifications"; -import { NotFound } from "./+404"; -import { UserManagement } from "./+user-management/user-management"; -import { ConfirmDialog } from "./confirm-dialog"; -import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog"; -import { Nodes } from "./+nodes"; -import { Workloads } from "./+workloads"; -import { Namespaces } from "./+namespaces"; -import { Network } from "./+network"; -import { Storage } from "./+storage"; -import { ClusterOverview } from "./+cluster/cluster-overview"; -import { Config } from "./+config"; -import { Events } from "./+events/events"; -import { Apps } from "./+apps"; -import { KubeObjectDetails } from "./kube-object/kube-object-details"; -import { AddRoleBindingDialog } from "./+user-management-roles-bindings"; -import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; -import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; -import { CustomResources } from "./+custom-resources/custom-resources"; -import { MainLayout } from "./layout/main-layout"; -import { ErrorBoundary } from "./error-boundary"; -import { Terminal } from "./dock/terminal"; +import whatInput from "what-input"; +import { setFrameId } from "../../common/cluster-ipc"; import { getHostedCluster } from "../../common/cluster-store"; -import logger from "../../main/logger"; -import { webFrame } from "electron"; -import { ClusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry"; -import { ExtensionLoader } from "../../extensions/extension-loader"; +import { getHostedClusterId } from "../../common/cluster-types"; import { appEventBus } from "../../common/event-bus"; import { requestMain } from "../../common/ipc"; -import whatInput from "what-input"; -import { clusterSetFrameIdHandler } from "../../common/cluster-ipc"; +import { ExtensionLoader } from "../../extensions/extension-loader"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry } from "../../extensions/registries"; -import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; -import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; -import { KubeWatchApi } from "../api/kube-watch-api"; -import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; -import { CommandContainer } from "./command-palette/command-container"; -import * as routes from "../../common/routes"; -import { getHostedClusterId } from "../../common/cluster-types"; -import { initApiManagerStores } from "../initializers/api-manager-stores"; -import { ApiManager } from "../api/api-manager"; +import { ClusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry"; import type { Cluster } from "../../main/cluster"; -import { eventApi, namespacesApi, nodesApi, podsApi } from "../api/endpoints"; +import logger from "../../main/logger"; +import { ApiManager } from "../api/api-manager"; +import { podsApi, nodesApi, eventApi, namespacesApi } from "../api/endpoints"; +import { KubeWatchApi } from "../api/kube-watch-api"; +import { initApiManagerStores } from "../initializers/api-manager-stores"; +import { history } from "../navigation"; +import { NotFound } from "./+404"; +import { Apps } from "./+apps"; import { ReleaseStore } from "./+apps-releases/release.store"; +import { ClusterOverview } from "./+cluster/cluster-overview"; +import { Config } from "./+config"; +import { CustomResources } from "./+custom-resources/custom-resources"; +import { Events } from "./+events/events"; +import { Namespaces } from "./+namespaces"; +import { Network } from "./+network"; +import { Nodes } from "./+nodes"; +import { Storage } from "./+storage"; +import { AddRoleBindingDialog } from "./+user-management-roles-bindings"; +import { UserManagement } from "./+user-management/user-management"; +import { Workloads } from "./+workloads"; +import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; +import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; +import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; +import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; +import { CommandContainer } from "./command-palette/command-container"; +import { ConfirmDialog } from "./confirm-dialog"; +import { Terminal } from "./dock/terminal"; +import { ErrorBoundary } from "./error-boundary"; +import { KubeObjectDetails } from "./kube-object/kube-object-details"; +import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog"; +import { MainLayout } from "./layout/main-layout"; +import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; +import { Notifications } from "./notifications"; +import * as routes from "../../common/routes"; @observer export class ClusterFrame extends React.Component { @@ -74,12 +72,12 @@ export class ClusterFrame extends React.Component { static cluster: Cluster; static async init() { - const frameId = webFrame.routingId; + const frameId = Electron.webFrame.routingId; const clusterId = getHostedClusterId(); logger.info(`[APP]: Init dashboard, clusterId=${clusterId}, frameId=${frameId}`); await Terminal.preloadFonts(); - await requestMain(clusterSetFrameIdHandler, clusterId); + await requestMain(setFrameId, clusterId); this.cluster = getHostedCluster(); diff --git a/src/renderer/components/badge/badge.tsx b/src/renderer/components/badge/badge.tsx index 57d1e8917a..dc2b6b8dd5 100644 --- a/src/renderer/components/badge/badge.tsx +++ b/src/renderer/components/badge/badge.tsx @@ -18,14 +18,14 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - import "./badge.scss"; import React from "react"; + import { cssNames } from "../../utils/cssNames"; import { TooltipDecoratorProps, withTooltip } from "../tooltip"; -export interface BadgeProps extends React.HTMLAttributes, TooltipDecoratorProps { +export interface BadgeProps extends React.DetailedHTMLProps, HTMLSpanElement>, TooltipDecoratorProps { small?: boolean; flat?: boolean; label?: React.ReactNode; @@ -36,11 +36,11 @@ export class Badge extends React.Component { render() { const { className, label, small, flat, children, ...elemProps } = this.props; - return <> + return ( {label} {children} - ; + ); } } diff --git a/src/renderer/components/button/button.tsx b/src/renderer/components/button/button.tsx index 83d4cc18bf..700714c059 100644 --- a/src/renderer/components/button/button.tsx +++ b/src/renderer/components/button/button.tsx @@ -20,7 +20,9 @@ */ import "./button.scss"; + import React, { ButtonHTMLAttributes } from "react"; + import { cssNames } from "../../utils"; import { TooltipDecoratorProps, withTooltip } from "../tooltip"; diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 90cbdb2556..2920abf138 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -18,12 +18,8 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; - import "./cluster-status.scss"; -import React from "react"; -import { observer } from "mobx-react"; + import { ipcRenderer } from "electron"; import { computed, observable } from "mobx"; import { ipcRendererOn, requestMain } from "../../../common/ipc"; @@ -34,7 +30,10 @@ import type { Cluster } from "../../../main/cluster"; import { ClusterStore } from "../../../common/cluster-store"; import type { ClusterId } from "../../../common/cluster-types"; import { CubeSpinner } from "../spinner"; -import { clusterActivateHandler } from "../../../common/cluster-ipc"; +import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; +import { observer } from "mobx-react"; +import React from "react-transition-group/node_modules/@types/react"; +import { activate } from "../../../common/cluster-ipc"; interface Props { className?: IClassName; @@ -68,7 +67,7 @@ export class ClusterStatus extends React.Component { } activateCluster = async (force = false) => { - await requestMain(clusterActivateHandler, this.props.clusterId, force); + await requestMain(activate, this.props.clusterId, force); }; reconnect = async () => { diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index 08d67a5312..9daed0bd71 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -20,6 +20,7 @@ */ import "./cluster-view.scss"; + import React from "react"; import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; @@ -29,10 +30,10 @@ import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import type { Cluster } from "../../../main/cluster"; import { ClusterStore } from "../../../common/cluster-store"; import { requestMain } from "../../../common/ipc"; -import { clusterActivateHandler } from "../../../common/cluster-ipc"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { navigate } from "../../navigation"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; +import { activate } from "../../../common/cluster-ipc"; +import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; interface Props extends RouteComponentProps { } @@ -69,20 +70,20 @@ export class ClusterView extends React.Component { showCluster(clusterId: string) { initView(clusterId); - requestMain(clusterActivateHandler, this.clusterId, false); + requestMain(activate, this.clusterId, false); - const entity = catalogEntityRegistry.getById(this.clusterId); + const entity = CatalogEntityRegistry.getInstance().getById(this.clusterId); if (entity) { - catalogEntityRegistry.activeEntity = entity; + CatalogEntityRegistry.getInstance().activeEntity = entity; } } hideCluster() { refreshViews(); - if (catalogEntityRegistry.activeEntity?.metadata?.uid === this.clusterId) { - catalogEntityRegistry.activeEntity = null; + if (CatalogEntityRegistry.getInstance().activeEntity?.metadata?.uid === this.clusterId) { + CatalogEntityRegistry.getInstance().activeEntity = null; } } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index bdd00859c2..4b913a223a 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -25,13 +25,12 @@ import React, { DOMAttributes, useState } from "react"; import { Avatar } from "@material-ui/core"; import randomColor from "randomcolor"; import GraphemeSplitter from "grapheme-splitter"; - -import type { CatalogEntityContextMenu } from "../../../common/catalog"; import { cssNames, IClassName, iter } from "../../utils"; import { ConfirmDialog } from "../confirm-dialog"; import { Menu, MenuItem } from "../menu"; import { MaterialTooltip } from "../+catalog/material-tooltip/material-tooltip"; import { observer } from "mobx-react"; +import type { ContextMenu } from "../../api/catalog-entity"; interface Props extends DOMAttributes { uid: string; @@ -40,7 +39,7 @@ interface Props extends DOMAttributes { onMenuOpen?: () => void; className?: IClassName; active?: boolean; - menuItems?: CatalogEntityContextMenu[]; + menuItems?: ContextMenu[]; disabled?: boolean; } @@ -50,7 +49,7 @@ function generateAvatarStyle(seed: string): React.CSSProperties { }; } -function onMenuItemClick(menuItem: CatalogEntityContextMenu) { +function onMenuItemClick(menuItem: ContextMenu) { if (menuItem.confirm) { ConfirmDialog.open({ okButtonProps: { diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index a69172bad3..ba2d62f66f 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -25,7 +25,7 @@ import React from "react"; import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store"; import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; @@ -44,6 +44,10 @@ export class HotbarMenu extends React.Component { return HotbarStore.getInstance().getActive(); } + isActive(item: CatalogEntity) { + return CatalogEntityRegistry.getInstance().activeEntity?.metadata?.uid == item.getId(); + } + getEntity(item: HotbarItem) { const hotbar = HotbarStore.getInstance().getActive(); @@ -51,7 +55,7 @@ export class HotbarMenu extends React.Component { return null; } - return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null; + return item ? CatalogEntityRegistry.getInstance().items.find((entity) => entity.metadata.uid === item.entity.uid) : null; } onDragEnd(result: DropResult) { @@ -91,7 +95,7 @@ export class HotbarMenu extends React.Component { @computed get items() { const items = this.hotbar.items; - const activeEntity = catalogEntityRegistry.activeEntity; + const activeEntity = CatalogEntityRegistry.getInstance().activeEntity; if (!activeEntity) return items; diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index de962ace97..4e9a80005d 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -22,8 +22,8 @@ import "./menu-actions.scss"; import React, { isValidElement } from "react"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; +import { observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; import { autobind, cssNames } from "../../utils"; import { ConfirmDialog } from "../confirm-dialog"; import { Icon, IconProps } from "../icon"; @@ -40,6 +40,7 @@ export interface MenuActionsProps extends Partial { updateAction?(): void; removeAction?(): void; onOpen?(): void; + onClose?(): void; } @observer @@ -54,6 +55,12 @@ export class MenuActions extends React.Component { @observable isOpen = !!this.props.toolbar; + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => !this.isOpen, () => this.props.onClose?.()), + ]); + } + toggle = () => { if (this.props.toolbar) return; this.isOpen = !this.isOpen; diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index afec8bfde6..df769030a4 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -35,14 +35,14 @@ import { LensProtocolRouterRenderer, bindProtocolAddRouteHandlers } from "./prot import { registerIpcHandlers } from "./ipc"; import { ipcRenderer } from "electron"; import { IpcRendererNavigationEvents } from "./navigation/events"; -import { catalogEntityRegistry } from "./api/catalog-entity-registry"; import { CommandRegistry } from "../extensions/registries"; +import { CatalogEntityRegistry } from "./api/catalog-entity-registry"; import { reaction } from "mobx"; @observer export class LensApp extends React.Component { static async init() { - catalogEntityRegistry.init(); + CatalogEntityRegistry.createInstance().init(); ExtensionLoader.getInstance().loadOnClusterManagerRenderer(); LensProtocolRouterRenderer.createInstance().init(); bindProtocolAddRouteHandlers(); @@ -55,7 +55,7 @@ export class LensApp extends React.Component { } componentDidMount() { - reaction(() => catalogEntityRegistry.items, (items) => { + reaction(() => CatalogEntityRegistry.getInstance().items, (items) => { const reg = CommandRegistry.getInstance(); if (reg.activeEntity && items.includes(reg.activeEntity)) { diff --git a/src/renderer/protocol-handler/app-handlers.ts b/src/renderer/protocol-handler/app-handlers.ts index bcb9728544..d38221cea5 100644 --- a/src/renderer/protocol-handler/app-handlers.ts +++ b/src/renderer/protocol-handler/app-handlers.ts @@ -22,7 +22,7 @@ import { attemptInstallByInfo } from "../components/+extensions"; import { LensProtocolRouterRenderer } from "./router"; import { navigate } from "../navigation/helpers"; -import { catalogEntityRegistry } from "../api/catalog-entity-registry"; +import { CatalogEntityRegistry } from "../api/catalog-entity-registry"; import { ClusterStore } from "../../common/cluster-store"; import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler"; import * as routes from "../../common/routes"; @@ -43,7 +43,7 @@ export function bindProtocolAddRouteHandlers() { navigate(routes.addClusterURL()); }) .addInternalHandler("/entity/:entityId/settings", ({ pathname: { entityId } }) => { - const entity = catalogEntityRegistry.getById(entityId); + const entity = CatalogEntityRegistry.getInstance().getById(entityId); if (entity) { navigate(routes.entitySettingsURL({ params: { entityId } })); diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index 862d79a43a..51fa0079aa 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -51,6 +51,9 @@ export function createStorage(key: string, defaultValue: T, observableOptions // bind auto-saving reaction(() => storage.toJSON(), saveFile, { delay: 250 }); + + // We don't clean up the cluster because it might come back in the future + // as cluster IDs are now deterministic based on "path" + "contextName" } async function saveFile(json = {}) { diff --git a/webpack.extensions.ts b/webpack.extensions.ts index 1303966e32..a55fed4e4f 100644 --- a/webpack.extensions.ts +++ b/webpack.extensions.ts @@ -117,6 +117,9 @@ export default function generateExtensionTypes(): webpack.Configuration { extensions: [".ts", ".tsx", ".js"] }, plugins: [ + new CircularDependencyPlugin({ + exclude: /node_modules/, + }), // In ts-loader's README they said to output a built .d.ts file, // you can set "declaration": true in tsconfig.extensions.json, // and use the DeclarationBundlerPlugin in your webpack config... but diff --git a/yarn.lock b/yarn.lock index 6d4c5a53e5..046f18ddd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1644,20 +1644,13 @@ dependencies: "@types/react" "*" -"@types/react@*": - version "16.9.35" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368" - integrity sha512-q0n0SsWcGc8nDqH2GJfWQWUOmZSJhXV64CjVN5SvcNti3TdEaA3AH0D8DwNmMdzjMAC/78tB8nAZIlV8yTz+zQ== - dependencies: - "@types/prop-types" "*" - csstype "^2.2.0" - -"@types/react@^17.0.0": - version "17.0.0" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" - integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw== +"@types/react@*", "@types/react@^17.0.0": + version "17.0.5" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.5.tgz#3d887570c4489011f75a3fc8f965bf87d09a1bea" + integrity sha512-bj4biDB9ZJmGAYTWSKJly6bMr4BLUiBrx9ujiJEoP9XIDY9CTaPGxE5QWN/1WjpPLzYF7/jRNnV2nNxNe970sw== dependencies: "@types/prop-types" "*" + "@types/scheduler" "*" csstype "^3.0.2" "@types/readable-stream@^2.3.9": @@ -1695,6 +1688,11 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/scheduler@*": + version "0.16.1" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" + integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== + "@types/semver@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.2.0.tgz#0d72066965e910531e1db4621c15d0ca36b8d83b" @@ -4355,7 +4353,7 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.7, csstype@^2.6.5, csstype@^2.6.7: +csstype@^2.5.2, csstype@^2.5.7, csstype@^2.6.5, csstype@^2.6.7: version "2.6.10" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==