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

make Pages and PageMenus observable and reactive

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-02-26 16:14:34 -05:00
parent e718b250cc
commit 1e2069466a
14 changed files with 385 additions and 248 deletions

View File

@ -1,6 +1,6 @@
// Lens-extensions api developer's kit // Lens-extensions api developer's kit
export * from "../lens-main-extension"; export { LensMainExtension } from "../lens-main-extension";
export * from "../lens-renderer-extension"; export { LensRendererExtension } from "../lens-renderer-extension";
// APIs // APIs
import * as App from "./app"; import * as App from "./app";

View File

@ -14,7 +14,6 @@ import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries"; import * as registries from "./registries";
import fs from "fs"; import fs from "fs";
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData")); return path.join((app || remote.app).getPath("userData"));
} }
@ -66,10 +65,22 @@ export class ExtensionLoader {
return extensions; return extensions;
} }
@computed get allEnabledInstances(): LensExtension[] {
const res: LensExtension[] = [];
for (const [extId, ext] of this.instances) {
if (this.extensions.get(extId).isEnabled) {
res.push(ext);
}
}
return res;
}
getExtensionByName(name: string): LensExtension | null { getExtensionByName(name: string): LensExtension | null {
for (const [, val] of this.instances) { for (const [extId, ext] of this.instances) {
if (val.name === name) { if (ext.name === name && this.extensions.get(extId).isEnabled) {
return val; return ext;
} }
} }
@ -210,8 +221,6 @@ export class ExtensionLoader {
logger.debug(`${logModule}: load on main renderer (cluster manager)`); logger.debug(`${logModule}: load on main renderer (cluster manager)`);
this.autoInitExtensions(async (extension: LensRendererExtension) => { this.autoInitExtensions(async (extension: LensRendererExtension) => {
const removeItems = [ const removeItems = [
registries.globalPageRegistry.add(extension.globalPages, extension),
registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension),
registries.appPreferenceRegistry.add(extension.appPreferences), registries.appPreferenceRegistry.add(extension.appPreferences),
registries.clusterFeatureRegistry.add(extension.clusterFeatures), registries.clusterFeatureRegistry.add(extension.clusterFeatures),
registries.statusBarRegistry.add(extension.statusBarItems), registries.statusBarRegistry.add(extension.statusBarItems),
@ -240,8 +249,6 @@ export class ExtensionLoader {
} }
const removeItems = [ const removeItems = [
registries.clusterPageRegistry.add(extension.clusterPages, extension),
registries.clusterPageMenuRegistry.add(extension.clusterPageMenus, extension),
registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems), registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems),
registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems), registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems),
registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts), registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts),

View File

@ -9,7 +9,7 @@ export class LensMainExtension extends LensExtension {
async navigate<P extends object>(pageId?: string, params?: P, frameId?: number) { async navigate<P extends object>(pageId?: string, params?: P, frameId?: number) {
const windowManager = WindowManager.getInstance<WindowManager>(); const windowManager = WindowManager.getInstance<WindowManager>();
const pageUrl = getExtensionPageUrl({ const pageUrl = getExtensionPageUrl({
extensionId: this.name, extensionName: this.name,
pageId, pageId,
params: params ?? {}, // compile to url with params params: params ?? {}, // compile to url with params
}); });

View File

@ -1,14 +1,56 @@
import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"; import { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, getRegisteredPage, Registrable, StatusBarRegistration, recitfyRegisterable, getRegisteredPageMenu, } from "./registries";
import type { Cluster } from "../main/cluster"; import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension"; import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry"; import { getExtensionPageUrl } from "./registries/page-registry";
import { CommandRegistration } from "./registries/command-registry"; import { CommandRegistration } from "./registries/command-registry";
import { computed, observable } from "mobx";
import { getHostedCluster } from "../common/cluster-store";
export const registeredClusterPages = Symbol("registeredClusterPages");
export const registeredGlobalPages = Symbol("registeredGlobalPages");
export const registeredGlobalPageMenus = Symbol("registeredGlobalPageMenus");
export const registeredClusterPageMenus = Symbol("registeredClusterPageMenus");
export class LensRendererExtension extends LensExtension { export class LensRendererExtension extends LensExtension {
globalPages: PageRegistration[] = []; #privateGetters = {
clusterPages: PageRegistration[] = []; [registeredGlobalPages]: computed(() => (
globalPageMenus: PageMenuRegistration[] = []; recitfyRegisterable(this.globalPages)
clusterPageMenus: ClusterPageMenuRegistration[] = []; .map(page => getRegisteredPage(page, this.name))
)),
[registeredClusterPages]: computed(() => (
recitfyRegisterable(this.clusterPages, getHostedCluster)
.map(page => getRegisteredPage(page, this.name))
)),
[registeredGlobalPageMenus]: computed(() => (
recitfyRegisterable(this.globalPageMenus)
.map(pageMenu => getRegisteredPageMenu(pageMenu, this.name))
)),
[registeredClusterPageMenus]: computed(() => (
recitfyRegisterable(this.clusterPageMenus, getHostedCluster)
.map(pageMenu => getRegisteredPageMenu(pageMenu, this.name))
)),
};
@observable globalPages: Registrable<PageRegistration> = [];
get [registeredGlobalPages]() {
return this.#privateGetters[registeredGlobalPages].get();
}
@observable clusterPages: Registrable<PageRegistration> = [];
get [registeredClusterPages]() {
return this.#privateGetters[registeredClusterPages].get();
}
@observable globalPageMenus: Registrable<PageMenuRegistration> = [];
get [registeredGlobalPageMenus]() {
return this.#privateGetters[registeredGlobalPageMenus].get();
}
@observable clusterPageMenus: Registrable<ClusterPageMenuRegistration> = [];
get [registeredClusterPageMenus]() {
return this.#privateGetters[registeredClusterPageMenus].get();
}
kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
appPreferences: AppPreferenceRegistration[] = []; appPreferences: AppPreferenceRegistration[] = [];
clusterFeatures: ClusterFeatureRegistration[] = []; clusterFeatures: ClusterFeatureRegistration[] = [];
@ -20,7 +62,7 @@ export class LensRendererExtension extends LensExtension {
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); const { navigate } = await import("../renderer/navigation");
const pageUrl = getExtensionPageUrl({ const pageUrl = getExtensionPageUrl({
extensionId: this.name, extensionName: this.name,
pageId, pageId,
params: params ?? {}, // compile to url with params params: params ?? {}, // compile to url with params
}); });

View File

@ -1,126 +1,134 @@
import { getExtensionPageUrl, globalPageRegistry, PageParams } from "../page-registry"; import { getExtensionPageUrl, PageParams } from "../page-registry";
import { LensExtension } from "../../lens-extension";
import React from "react"; import React from "react";
import { LensRendererExtension } from "../../core-api";
import { findRegisteredPage, PageRegistration } from "..";
import { extensionLoader } from "../../extension-loader";
let ext: LensExtension = null; jest.mock("../../extension-loader");
describe("getPageUrl", () => { describe("getPageUrl", () => {
const extensionName = "foo-bar";
beforeEach(async () => { beforeEach(async () => {
ext = new LensExtension({ jest.spyOn(extensionLoader, "getExtensionByName")
manifest: { .mockImplementation(name => {
name: "foo-bar", if (name !== extensionName) {
version: "0.1.1" return undefined;
}, }
id: "/this/is/fake/package.json",
absolutePath: "/absolute/fake/", const ext = new LensRendererExtension({
manifestPath: "/this/is/fake/package.json", manifest: {
isBundled: false, name: extensionName,
isEnabled: true version: "0.1.1"
}); },
globalPageRegistry.add({ id: "/this/is/fake/package.json",
id: "page-with-params", absolutePath: "/absolute/fake/",
components: { manifestPath: "/this/is/fake/package.json",
Page: () => React.createElement("Page with params") isBundled: false,
}, isEnabled: true
params: { });
test1: "test1-default",
test2: "" // no default value, just declaration (ext.globalPages as PageRegistration[]).push({
}, id: "page-with-params",
}, ext); components: {
Page: () => React.createElement("Page with params")
},
params: {
test1: "test1-default",
test2: "" // no default value, just declaration
},
});
return ext;
});
}); });
it("returns a page url for extension", () => { it("returns a page url for extension", () => {
expect(getExtensionPageUrl({ extensionId: ext.name })).toBe("/extension/foo-bar"); expect(getExtensionPageUrl({ extensionName })).toBe("/extension/foo-bar");
}); });
it("allows to pass base url as parameter", () => { it("allows to pass base url as parameter", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test"); expect(getExtensionPageUrl({ extensionName, pageId: "/test" })).toBe("/extension/foo-bar/test");
}); });
it("removes @ and replace `/` to `--`", () => { it("removes @ and replace `/` to `--`", () => {
expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar"); expect(getExtensionPageUrl({ extensionName: "@foo/bar" })).toBe("/extension/foo--bar");
}); });
it("adds / prefix", () => { it("adds / prefix", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test"); expect(getExtensionPageUrl({ extensionName, pageId: "test" })).toBe("/extension/foo-bar/test");
}); });
it("normalize possible multi-slashes in page.id", () => { it("normalize possible multi-slashes in page.id", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test"); expect(getExtensionPageUrl({ extensionName, pageId: "//test/" })).toBe("/extension/foo-bar/test");
}); });
it("gets page url with custom params", () => { it("gets page url with custom params", () => {
const params: PageParams<string> = { test1: "one", test2: "2" }; const params: PageParams<string> = { test1: "one", test2: "2" };
const searchParams = new URLSearchParams(params); const searchParams = new URLSearchParams(params);
const pageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", params }); const pageUrl = getExtensionPageUrl({ extensionName, pageId: "page-with-params", params });
expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`); expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`);
}); });
it("gets page url with default custom params", () => { it("gets page url with default custom params", () => {
const defaultPageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", }); const defaultPageUrl = getExtensionPageUrl({ extensionName, pageId: "page-with-params", });
expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`); expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`);
}); });
}); });
describe("globalPageRegistry", () => { describe("globalPageRegistry", () => {
beforeEach(async () => { const extensionName = "@acme/foo-bar";
ext = new LensExtension({ const ext = new LensRendererExtension({
manifest: { manifest: {
name: "@acme/foo-bar", name: extensionName,
version: "0.1.1" version: "0.1.1"
}, },
id: "/this/is/fake/package.json", id: "/this/is/fake/package.json",
absolutePath: "/absolute/fake/", absolutePath: "/absolute/fake/",
manifestPath: "/this/is/fake/package.json", manifestPath: "/this/is/fake/package.json",
isBundled: false, isBundled: false,
isEnabled: true isEnabled: true
});
globalPageRegistry.add([
{
id: "test-page",
components: {
Page: () => React.createElement("Text")
}
},
{
id: "another-page",
components: {
Page: () => React.createElement("Text")
},
},
{
components: {
Page: () => React.createElement("Default")
}
},
], ext);
}); });
describe("getByPageTarget", () => { (ext.globalPages as PageRegistration[]).push(
{
id: "test-page",
components: {
Page: () => React.createElement("Text")
}
},
{
id: "another-page",
components: {
Page: () => React.createElement("Text")
},
},
{
components: {
Page: () => React.createElement("Default")
}
},
);
describe("findRegisteredPage", () => {
it("matching to first registered page without id", () => { it("matching to first registered page without id", () => {
const page = globalPageRegistry.getByPageTarget({ extensionId: ext.name }); const page = findRegisteredPage(ext);
expect(page.id).toEqual(undefined); expect(page.id).toEqual(undefined);
expect(page.extensionId).toEqual(ext.name); expect(page.extensionName).toEqual(ext.name);
expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name })); expect(page.url).toEqual(getExtensionPageUrl({ extensionName }));
}); });
it("returns matching page", () => { it("returns matching page", () => {
const page = globalPageRegistry.getByPageTarget({ const page = findRegisteredPage(ext, "test-page");
pageId: "test-page",
extensionId: ext.name
});
expect(page.id).toEqual("test-page"); expect(page.id).toEqual("test-page");
}); });
it("returns null if target not found", () => { it("returns null if target not found", () => {
const page = globalPageRegistry.getByPageTarget({ const page = findRegisteredPage(ext, "wrong-page");
pageId: "wrong-page",
extensionId: ext.name
});
expect(page).toBeNull(); expect(page).toBeNull();
}); });

View File

@ -20,8 +20,9 @@ export class BaseRegistry<T, I = T> {
return () => this.remove(...itemArray); return () => this.remove(...itemArray);
} }
// eslint-disable-next-line unused-imports/no-unused-vars-ts
protected getRegisteredItem(item: T, extension?: LensExtension): I { protected getRegisteredItem(item: T, extension?: LensExtension): I {
void extension;
return item as any; return item as any;
} }

View File

@ -1,5 +1,7 @@
// All registries managed by extensions api // All registries managed by extensions api
import { Cluster } from "../../main/cluster";
export * from "./page-registry"; export * from "./page-registry";
export * from "./page-menu-registry"; export * from "./page-menu-registry";
export * from "./menu-registry"; export * from "./menu-registry";
@ -10,3 +12,14 @@ export * from "./kube-object-menu-registry";
export * from "./cluster-feature-registry"; export * from "./cluster-feature-registry";
export * from "./kube-object-status-registry"; export * from "./kube-object-status-registry";
export * from "./command-registry"; export * from "./command-registry";
export * from "./sources";
export type Registrable<T> = (T[]) | ((cluster?: Cluster | null) => T[]);
export function recitfyRegisterable<T>(src: Registrable<T>, getCluster?: () => Cluster | null | undefined): T[] {
if (typeof src === "function") {
return src(getCluster());
}
return src;
}

View File

@ -2,9 +2,14 @@
import type { IconProps } from "../../renderer/components/icon"; import type { IconProps } from "../../renderer/components/icon";
import type React from "react"; import type React from "react";
import type { PageTarget, RegisteredPage } from "./page-registry"; import type { PageTarget, RegisteredPage } from "./page-registry";
import { action } from "mobx"; import { RegisteredPageTarget } from ".";
import { BaseRegistry } from "./base-registry"; import { LensRendererExtension } from "../core-api";
import { LensExtension } from "../lens-extension"; import { extensionLoader } from "../extension-loader";
import { registeredClusterPageMenus, registeredGlobalPageMenus } from "../lens-renderer-extension";
export interface PageMenuComponents {
Icon: React.ComponentType<IconProps>;
}
export interface PageMenuRegistration { export interface PageMenuRegistration {
target?: PageTarget; target?: PageTarget;
@ -12,50 +17,57 @@ export interface PageMenuRegistration {
components: PageMenuComponents; components: PageMenuComponents;
} }
export interface RegisteredPageMenuTarget {
target: RegisteredPageTarget;
}
export type RegisteredPageMenu = PageMenuRegistration & RegisteredPageMenuTarget;
export interface ClusterPageMenuRegistration extends PageMenuRegistration { export interface ClusterPageMenuRegistration extends PageMenuRegistration {
id?: string; id?: string;
parentId?: string; parentId?: string;
} }
export interface PageMenuComponents { export type RegisteredClusterPageMenu = ClusterPageMenuRegistration & RegisteredPageMenuTarget;
Icon: React.ComponentType<IconProps>;
export function getRegisteredPageMenu<T extends PageMenuRegistration>({ target: { pageId, params } = {}, ...rest }: T, extensionName: string): T & RegisteredPageMenuTarget {
const target: RegisteredPageTarget = {
params,
pageId,
extensionName,
};
return { ...rest, target } as T & RegisteredPageMenuTarget;
} }
export class PageMenuRegistry<T extends PageMenuRegistration> extends BaseRegistry<T> { export function getGlobalPageMenus(): RegisteredPageMenu[] {
@action const extensions = extensionLoader.allEnabledInstances as LensRendererExtension[];
add(items: T[], ext: LensExtension) {
const normalizedItems = items.map(menuItem => {
menuItem.target = {
extensionId: ext.name,
...(menuItem.target || {}),
};
return menuItem; return extensions.flatMap(ext => ext[registeredGlobalPageMenus]);
});
return super.add(normalizedItems);
}
} }
export class ClusterPageMenuRegistry extends PageMenuRegistry<ClusterPageMenuRegistration> { function getClusterPageMenus(): RegisteredClusterPageMenu[] {
getRootItems() { const extensions = extensionLoader.allEnabledInstances as LensRendererExtension[];
return this.getItems().filter((item) => !item.parentId);
}
getSubItems(parent: ClusterPageMenuRegistration) { return extensions.flatMap(ext => ext[registeredClusterPageMenus]);
return this.getItems().filter((item) => ( }
item.parentId === parent.id &&
item.target.extensionId === parent.target.extensionId export function getRootClusterPageMenus(): RegisteredClusterPageMenu[] {
return getClusterPageMenus().filter(pageMenu => !pageMenu.parentId);
}
export function getChildClusterPageMenus(parentMenu: RegisteredClusterPageMenu): RegisteredClusterPageMenu[] {
return getClusterPageMenus()
.filter(pageMenu => (
pageMenu.parentId === parentMenu.id
&& pageMenu.target.extensionName === parentMenu.target.extensionName
)); ));
}
getByPage({ id: pageId, extensionId }: RegisteredPage) {
return this.getItems().find((item) => (
item.target.pageId == pageId &&
item.target.extensionId === extensionId
));
}
} }
export const globalPageMenuRegistry = new PageMenuRegistry(); export function getClusterPageMenuByPage({ id: pageId, extensionName }: RegisteredPage): RegisteredClusterPageMenu {
export const clusterPageMenuRegistry = new ClusterPageMenuRegistry(); return getClusterPageMenus()
.find(pageMenu => (
pageMenu.target.pageId == pageId
&& pageMenu.target.extensionName === extensionName
));
}

View File

@ -2,10 +2,15 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { BaseRegistry } from "./base-registry"; import { sanitizeExtensionName } from "../lens-extension";
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
import { PageParam, PageParamInit } from "../../renderer/navigation/page-param"; import { PageParam, PageParamInit } from "../../renderer/navigation/page-param";
import { createPageParam } from "../../renderer/navigation/helpers"; import { createPageParam } from "../../renderer/navigation/helpers";
import { extensionLoader } from "../extension-loader";
import { LensRendererExtension } from "../core-api";
import { registeredClusterPages, registeredGlobalPages } from "../lens-renderer-extension";
import { TabLayoutRoute } from "../renderer-api/components";
import { RegistrationScope } from "./sources";
import { getChildClusterPageMenus, RegisteredClusterPageMenu } from "./page-menu-registry";
export interface PageRegistration { export interface PageRegistration {
/** /**
@ -24,12 +29,16 @@ export interface PageComponents {
Page: React.ComponentType<any>; Page: React.ComponentType<any>;
} }
export interface PageTarget<P = PageParams> { export interface PageTarget<P extends PageParams = PageParams> {
extensionId?: string; extensionName?: string;
pageId?: string; pageId?: string;
params?: P; params?: P;
} }
export interface RegisteredPageTarget<P extends PageParams = PageParams> extends PageTarget<P> {
extensionName: string
}
export interface PageParams<V = any> { export interface PageParams<V = any> {
[paramName: string]: V; [paramName: string]: V;
} }
@ -42,81 +51,153 @@ export interface PageComponentProps<P extends PageParams = {}> {
export interface RegisteredPage { export interface RegisteredPage {
id: string; id: string;
extensionId: string; extensionName: string;
url: string; // registered extension's page URL (without page params) url: string; // registered extension's page URL (without page params)
params: PageParams<PageParam>; // normalized params params: PageParams<PageParam>; // normalized params
components: PageComponents; // normalized components components: PageComponents; // normalized components
} }
export function getExtensionPageUrl(target: PageTarget): string { /**
const { extensionId, pageId = "", params: targetParams = {} } = target; * Finds the first registered page on `extension` matching `pageId` in all of `sources`' scopes
* @param extension The extension to query for a matching `RegisteredPage`
* @param pageId The `PageId` to search for
* @param sources Whether to search for global pages or cluster pages or both
*/
export function findRegisteredPage(extension: LensRendererExtension | undefined, pageId?: string, sources = new Set([RegistrationScope.GLOBAL, RegistrationScope.CLUSTER])): RegisteredPage | null {
if (sources.has(RegistrationScope.GLOBAL)) {
const page = extension?.[registeredGlobalPages].find(page => page.id === pageId);
const pagePath = ["/extension", sanitizeExtensionName(extensionId), pageId] if (page) {
return page;
}
}
if (sources.has(RegistrationScope.CLUSTER)) {
const page = extension?.[registeredClusterPages].find(page => page.id === pageId);
if (page) {
return page;
}
}
return null;
}
export function getExtensionPageUrl(target: PageTarget): string {
const { extensionName, pageId = "", params: targetParams = {} } = target;
const pagePath = ["/extension", sanitizeExtensionName(extensionName), pageId]
.filter(Boolean) .filter(Boolean)
.join("/").replace(/\/+/g, "/").replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id) .join("/").replace(/\/+/g, "/").replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id)
const pageUrl = new URL(pagePath, `http://localhost`); const pageUrl = new URL(pagePath, `http://localhost`);
// stringify params to matched target page // stringify params to matched target page
const registeredPage = globalPageRegistry.getByPageTarget(target) || clusterPageRegistry.getByPageTarget(target); const extension = extensionLoader.getExtensionByName(extensionName);
if (registeredPage?.params) { if (extension instanceof LensRendererExtension) {
Object.entries(registeredPage.params).forEach(([name, param]) => { const registeredPage = findRegisteredPage(extension, target.pageId);
const paramValue = param.stringify(targetParams[name]);
if (param.init.skipEmpty && param.isEmpty(paramValue)) { if (registeredPage?.params) {
pageUrl.searchParams.delete(name); Object.entries(registeredPage.params).forEach(([name, param]) => {
} else { const paramValue = param.stringify(targetParams[name]);
pageUrl.searchParams.set(name, paramValue);
} if (param.init.skipEmpty && param.isEmpty(paramValue)) {
}); pageUrl.searchParams.delete(name);
} else {
pageUrl.searchParams.set(name, paramValue);
}
});
}
} }
return pageUrl.href.replace(pageUrl.origin, ""); return pageUrl.href.replace(pageUrl.origin, "");
} }
export class PageRegistry extends BaseRegistry<PageRegistration, RegisteredPage> { function normalizeComponents(components: PageComponents, params?: PageParams<PageParam>): PageComponents {
protected getRegisteredItem(page: PageRegistration, ext: LensExtension): RegisteredPage { if (params) {
const { id: pageId } = page; const { Page } = components;
const extensionId = ext.name;
const params = this.normalizeParams(page.params);
const components = this.normalizeComponents(page.components, params);
const url = getExtensionPageUrl({ extensionId, pageId });
return { components.Page = observer((props: object) => React.createElement(Page, { params, ...props }));
id: pageId, extensionId, params, components, url,
};
} }
protected normalizeComponents(components: PageComponents, params?: PageParams<PageParam>): PageComponents { return components;
if (params) { }
const { Page } = components;
components.Page = observer((props: object) => React.createElement(Page, { params, ...props })); function normalizeParams(params?: PageParams<string | ExtensionPageParamInit>): PageParams<PageParam> {
} if (!params) {
return;
return components;
} }
protected normalizeParams(params?: PageParams<string | ExtensionPageParamInit>): PageParams<PageParam> { Object.entries(params).forEach(([name, value]) => {
if (!params) { const paramInit: PageParamInit = typeof value === "object"
return; ? { name, ...value }
} : { name, defaultValue: value };
Object.entries(params).forEach(([name, value]) => {
const paramInit: PageParamInit = typeof value === "object"
? { name, ...value }
: { name, defaultValue: value };
params[paramInit.name] = createPageParam(paramInit); params[paramInit.name] = createPageParam(paramInit);
}); });
return params as PageParams<PageParam>; return params as PageParams<PageParam>;
} }
getByPageTarget(target: PageTarget): RegisteredPage | null { export function getRegisteredPage({ id, ...page}: PageRegistration, extensionName: string): RegisteredPage {
return this.getItems().find(page => page.extensionId === target.extensionId && page.id === target.pageId) || null; const params = normalizeParams(page.params);
const components = normalizeComponents(page.components);
const pagePath = ["/extension", sanitizeExtensionName(extensionName), id]
.filter(Boolean)
.join("/").replace(/\/+/g, "/").replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id)
const pageUrl = new URL(pagePath, `http://localhost`);
const url = pageUrl.href.replace(pageUrl.origin, "");
return { id, params, components, extensionName, url };
}
/**
* Find the `RegisteredPage` of an extension looking through all `sources`
* @param target The `extensionName` and `pageId` for the desired page
* @param sources Whether to search for global pages or cluster pages or both
*/
export function getByPageTarget(target?: PageTarget, sources = new Set([RegistrationScope.GLOBAL, RegistrationScope.CLUSTER])): RegisteredPage | null {
return findRegisteredPage(
extensionLoader.getExtensionByName(target?.extensionName) as LensRendererExtension,
target?.pageId,
sources,
);
}
/**
* Gets all the registered pages from all extensions
* @param source Whether to get all the global or cluster pages
*/
export function getAllRegisteredPages(source: RegistrationScope): RegisteredPage[] {
const extensions = extensionLoader.allEnabledInstances as LensRendererExtension[];
switch (source) {
case RegistrationScope.GLOBAL:
return extensions.flatMap(ext => ext[registeredGlobalPages]);
case RegistrationScope.CLUSTER:
return extensions.flatMap(ext => ext[registeredClusterPages]);
} }
} }
export const globalPageRegistry = new PageRegistry(); export function getTabLayoutRoutes(parentMenu: RegisteredClusterPageMenu): TabLayoutRoute[] {
export const clusterPageRegistry = new PageRegistry(); if (!parentMenu.id) {
return [];
}
return getChildClusterPageMenus(parentMenu)
.map(subMenu => [
getByPageTarget(subMenu.target),
subMenu,
] as const)
.filter(([subPage]) => subPage)
.map(([{ components, extensionName, id: pageId, url }, { title, target: { params } }]) => ({
routePath: url,
url: getExtensionPageUrl({ extensionName, pageId, params }),
title,
component: components.Page,
}));
}

View File

@ -0,0 +1,7 @@
/**
* This represents which sources the RegisteredPages should be searched from
*/
export enum RegistrationScope {
GLOBAL = "global",
CLUSTER = "cluster",
}

View File

@ -33,14 +33,13 @@ import { Terminal } from "./dock/terminal";
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store"; import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
import logger from "../../main/logger"; import logger from "../../main/logger";
import { webFrame } from "electron"; import { webFrame } from "electron";
import { clusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry";
import { extensionLoader } from "../../extensions/extension-loader"; import { extensionLoader } from "../../extensions/extension-loader";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import { broadcastMessage, requestMain } from "../../common/ipc"; import { broadcastMessage, requestMain } from "../../common/ipc";
import whatInput from "what-input"; import whatInput from "what-input";
import { clusterSetFrameIdHandler } from "../../common/cluster-ipc"; import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries"; import { getAllRegisteredPages, getByPageTarget, getChildClusterPageMenus, getClusterPageMenuByPage, getExtensionPageUrl, getRootClusterPageMenus, getTabLayoutRoutes, RegisteredClusterPageMenu, RegistrationScope } from "../../extensions/registries";
import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { TabLayout } from "./layout/tab-layout";
import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog";
import { eventStore } from "./+events/event.store"; import { eventStore } from "./+events/event.store";
import { nodesStore } from "./+nodes/nodes.store"; import { nodesStore } from "./+nodes/nodes.store";
@ -100,38 +99,32 @@ export class App extends React.Component {
return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); return nodesStore.getWarningsCount() + eventStore.getWarningsCount();
} }
getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { getTabLayoutRoutes(menuItem: RegisteredClusterPageMenu) {
const routes: TabLayoutRoute[] = [];
if (!menuItem.id) { if (!menuItem.id) {
return routes; return [];
} }
clusterPageMenuRegistry.getSubItems(menuItem).forEach((subMenu) => {
const page = clusterPageRegistry.getByPageTarget(subMenu.target);
if (page) { return getChildClusterPageMenus(menuItem)
routes.push({ .map(subMenu => [getByPageTarget(subMenu.target), subMenu] as const)
routePath: page.url, .filter(([page]) => page)
url: getExtensionPageUrl(subMenu.target), .map(([page, subMenu]) => ({
title: subMenu.title, routePath: page.url,
component: page.components.Page, url: getExtensionPageUrl(subMenu.target),
}); title: subMenu.title,
} component: page.components.Page,
}); }));
return routes;
} }
renderExtensionTabLayoutRoutes() { renderExtensionTabLayoutRoutes() {
return clusterPageMenuRegistry.getRootItems().map((menu, index) => { return getRootClusterPageMenus().map((menu, index) => {
const tabRoutes = this.getTabLayoutRoutes(menu); const tabRoutes = getTabLayoutRoutes(menu);
if (tabRoutes.length > 0) { if (tabRoutes.length > 0) {
const pageComponent = () => <TabLayout tabs={tabRoutes}/>; const pageComponent = () => <TabLayout tabs={tabRoutes}/>;
return <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)}/>; return <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)}/>;
} else { } else {
const page = clusterPageRegistry.getByPageTarget(menu.target); const page = getByPageTarget(menu.target, new Set([RegistrationScope.CLUSTER]));
if (page) { if (page) {
return <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page}/>; return <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page}/>;
@ -141,8 +134,8 @@ export class App extends React.Component {
} }
renderExtensionRoutes() { renderExtensionRoutes() {
return clusterPageRegistry.getItems().map((page, index) => { return getAllRegisteredPages(RegistrationScope.CLUSTER).map((page, index) => {
const menu = clusterPageMenuRegistry.getByPage(page); const menu = getClusterPageMenuByPage(page);
if (!menu) { if (!menu) {
return <Route key={`extension-route-${index}`} path={page.url} component={page.components.Page}/>; return <Route key={`extension-route-${index}`} path={page.url} component={page.components.Page}/>;

View File

@ -14,9 +14,9 @@ import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { clusterViewRoute, clusterViewURL } from "./cluster-view.route"; import { clusterViewRoute, clusterViewURL } from "./cluster-view.route";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
import { globalPageRegistry } from "../../../extensions/registries/page-registry";
import { Extensions, extensionsRoute } from "../+extensions"; import { Extensions, extensionsRoute } from "../+extensions";
import { getMatchedClusterId } from "../../navigation"; import { getMatchedClusterId } from "../../navigation";
import { getAllRegisteredPages, RegistrationScope } from "../../../extensions/registries";
@observer @observer
export class ClusterManager extends React.Component { export class ClusterManager extends React.Component {
@ -69,7 +69,7 @@ export class ClusterManager extends React.Component {
<Route component={AddCluster} {...addClusterRoute} /> <Route component={AddCluster} {...addClusterRoute} />
<Route component={ClusterView} {...clusterViewRoute} /> <Route component={ClusterView} {...clusterViewRoute} />
<Route component={ClusterSettings} {...clusterSettingsRoute} /> <Route component={ClusterSettings} {...clusterSettingsRoute} />
{globalPageRegistry.getItems().map(({ url, components: { Page } }) => { {getAllRegisteredPages(RegistrationScope.GLOBAL).map(({ url, components: { Page } }) => {
return <Route key={url} path={url} component={Page}/>; return <Route key={url} path={url} component={Page}/>;
})} })}
<Redirect exact to={this.startUrl}/> <Redirect exact to={this.startUrl}/>

View File

@ -15,7 +15,7 @@ import { addClusterURL } from "../+add-cluster";
import { landingURL } from "../+landing-page"; import { landingURL } from "../+landing-page";
import { clusterViewURL } from "./cluster-view.route"; import { clusterViewURL } from "./cluster-view.route";
import { ClusterActions } from "./cluster-actions"; import { ClusterActions } from "./cluster-actions";
import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; import { getByPageTarget, getExtensionPageUrl, getGlobalPageMenus, RegistrationScope } from "../../../extensions/registries";
import { commandRegistry } from "../../../extensions/registries/command-registry"; import { commandRegistry } from "../../../extensions/registries/command-registry";
import { CommandOverlay } from "../command-palette/command-container"; import { CommandOverlay } from "../command-palette/command-container";
import { computed, observable } from "mobx"; import { computed, observable } from "mobx";
@ -135,8 +135,8 @@ export class ClustersMenu extends React.Component<Props> {
</Menu> </Menu>
</div> </div>
<div className="extensions"> <div className="extensions">
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { {getGlobalPageMenus().map(({ title, target, components: { Icon } }) => {
const registeredPage = globalPageRegistry.getByPageTarget(target); const registeredPage = getByPageTarget(target, new Set([RegistrationScope.GLOBAL]));
if (!registeredPage){ if (!registeredPage){
return; return;

View File

@ -27,8 +27,8 @@ import { CustomResources } from "../+custom-resources/custom-resources";
import { isActiveRoute } from "../../navigation"; import { isActiveRoute } from "../../navigation";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { ClusterPageMenuRegistration, clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
import { SidebarItem } from "./sidebar-item"; import { SidebarItem } from "./sidebar-item";
import { getByPageTarget, getExtensionPageUrl, getRootClusterPageMenus, getTabLayoutRoutes, RegistrationScope } from "../../../extensions/registries";
interface Props { interface Props {
className?: string; className?: string;
@ -50,14 +50,12 @@ export class Sidebar extends React.Component<Props> {
} }
return Object.entries(crdStore.groups).map(([group, crds]) => { return Object.entries(crdStore.groups).map(([group, crds]) => {
const submenus: TabLayoutRoute[] = crds.map((crd) => { const submenus: TabLayoutRoute[] = crds.map(crd => ({
return { title: crd.getResourceKind(),
title: crd.getResourceKind(), component: CrdList,
component: CrdList, url: crd.getResourceUrl(),
url: crd.getResourceUrl(), routePath: String(crdResourcesRoute.path),
routePath: String(crdResourcesRoute.path), }));
};
});
return ( return (
<SidebarItem <SidebarItem
@ -72,43 +70,18 @@ export class Sidebar extends React.Component<Props> {
}); });
} }
getTabLayoutRoutes(menu: ClusterPageMenuRegistration): TabLayoutRoute[] {
const routes: TabLayoutRoute[] = [];
if (!menu.id) {
return routes;
}
clusterPageMenuRegistry.getSubItems(menu).forEach((subMenu) => {
const subPage = clusterPageRegistry.getByPageTarget(subMenu.target);
if (subPage) {
const { extensionId, id: pageId } = subPage;
routes.push({
routePath: subPage.url,
url: getExtensionPageUrl({ extensionId, pageId, params: subMenu.target.params }),
title: subMenu.title,
component: subPage.components.Page,
});
}
});
return routes;
}
renderRegisteredMenus() { renderRegisteredMenus() {
return clusterPageMenuRegistry.getRootItems().map((menuItem, index) => { return getRootClusterPageMenus().map((menuItem, index) => {
const registeredPage = clusterPageRegistry.getByPageTarget(menuItem.target); const registeredPage = getByPageTarget(menuItem.target, new Set([RegistrationScope.CLUSTER]));
const tabRoutes = this.getTabLayoutRoutes(menuItem); const tabRoutes = getTabLayoutRoutes(menuItem);
const id = `registered-item-${index}`; const id = `registered-item-${index}`;
let pageUrl: string; let pageUrl: string;
let isActive = false; let isActive = false;
if (registeredPage) { if (registeredPage) {
const { extensionId, id: pageId } = registeredPage; const { extensionName, id: pageId } = registeredPage;
pageUrl = getExtensionPageUrl({ extensionId, pageId, params: menuItem.target.params }); pageUrl = getExtensionPageUrl({ extensionName, pageId, params: menuItem.target.params });
isActive = isActiveRoute(registeredPage.url); isActive = isActiveRoute(registeredPage.url);
} else if (tabRoutes.length > 0) { } else if (tabRoutes.length > 0) {
pageUrl = tabRoutes[0].url; pageUrl = tabRoutes[0].url;